| 频段 (Hz) | 量化级别 (0-9) | 保留率 (0-1) |
|---|
最近我突发奇想想做一个音频压缩算法。起初,我尝试将采样精度压缩到8位,但发现这样直接降低采样精度会带来明显的量化噪声。于是,我考虑保存音频的频域数据,做法是将音频转换为FFT频域表示,并将相位和幅值分别量化到8位,幅值还经过了对数处理。这样处理后,效果确实比直接在时域中压缩到8位好很多,文件大小也从原来的wav文件的38MB减半到19MB,符合预期结果。
但相位数据的处理较为复杂。因此,我在网上搜索了一下,找了一种叫DCT的算法,便将处理流程从FFT转为DCT,结果效果差别不大,文件大小仍保持在原来的一半。于是,我开始尝试去除部分不重要的DCT系数,我设置了一个阈值,将低于此阈值的系数设为0。通过调整不同的阈值,我找到了一个在音质和压缩率之间相对平衡的数值。然而,文件大小仍没有减少,因为这些零值依然占用相同的存储空间。
于是,我打开文件用十六进制编辑器查看,发现这些零值呈连续分布。所以,我决定采用游程编码来压缩零值序列,最后再套一层gzip以进一步压缩。效果非常显著,文件大小再次减半至9MB。之后,我注意到中频部分的音频信息被过度丢弃,因此我建立了一个表格,对不同频段设置不同的阈值,以尽量保留人耳更敏感的频段信息,这样文件最终压缩到7MB。
最后,我想到了之前玩FM立体声发射时的思路:FM立体声通过L+R和L-R信号传输,L-R信号含有的信息相对较少。因此,我改为分别处理L+R和L-R信号,并对L-R信号设置更高的阈值。最终,文件大小压缩到6MB。到此,我发现自己无意中发现了音频压缩的经典思想。
自从上次我鼓捣出一个音频压缩格式,命名为 QFP(Quantized Frequency Packing?其实就是随手取的)之后,心里总感觉不太踏实。虽然能压缩,虽然能放出来,但……说实话,那声音一听就知道有事。
首先,QFP 是基于 DCT 的,而DCT 的边缘效应让我听得头皮发麻:音频块和音频块之间的切口感太强,就像是剪辑没对齐的拼接视频,一听就出戏。整段音乐听起来像在翻页播放,咔哒咔哒的。其次,压缩效率也不尽如人意,就像一个节俭却舍不得扔瓶瓶罐罐的打包方式,空间没省多少,音质还受了伤。
于是我决定来个 QFP v2 大改造计划。
这次我换了一个更“滑”的编码方式 —— MDCT(修正型离散余弦变换)。这东西和 DCT 是远房亲戚,但最大优势是可以“块块重叠”,再配合我选用的 Vorbis 风窗函数,能把各块拼接得天衣无缝。
原来的 DCT 压缩听起来像马赛克音频,现在的 MDCT 则像用面粉和水揉了又揉的面团,压出来的面条——丝滑流畅,再无硬边。
不过,压缩高频的时候问题又来了。声音变得“空洞”了,像是空气被抽走了一样,塑料感特别强,听起来就像有人在水下讲电话,还拿个塑料袋套着话筒。
我思来想去,决定玩个“障眼法”:噪声填充。你说高频细节被压掉了,那我干脆自己造点细节出来!
具体做法也不复杂,我每隔一段(比如每64个 MDCT 系数)就记一下它们的强度,像在压缩包里夹一张“噪声强度说明书”。然后在解压时,把这些“指导手册”读出来,用它们生成差不多强度的高斯白噪声,再撒回原位。
结果效果还不错!不过一开始频谱图看着就像方块积木搭出来的,生硬得一批,听起来也有种“马赛克味儿”的奇怪感。于是我又整了个线性插值,把噪声分布“磨皮”一下,视觉上自然了,听感上也没那么刺耳。
现在这个 QFPv2,大概用 12kbps 左右就能比较准确地还原人声。为啥呢?因为人说话主要就在 0~4kHz 这段来回折腾,高频辅音(比如“s”“f”“sh”这些带刺儿的)本身就像沙子一样,用白噪声一糊,反而还原得更自然,听起来不但没毛病,反而比原声还“圆润”了一点。
当然,要是你拿来压音乐,白噪声多、打击乐重的曲子表现还可以,用 64~80kbps 就基本能搞定。但像钢琴、小提琴这类“谐波灵魂”型乐器就吃亏了,得提高到 120~160kbps 才不失真。
当然,QFPv2 目前还是个“科研玩具”级别的作品。没做心理声学建模,没有掩蔽效应那一套(也就是“大音量附近的我就懒得存”策略),量化也很原始——谁小就砍谁,完全不考虑比特预算。更糟的是,一旦少读一个字节,整个解码就崩,像走钢丝一样脆弱。不像 MP3 那种“久经沙场”的格式,有同步头、容错机制、纠错码,全都安排得明明白白。
这趟旅程让我有种“在车库里造压缩算法”的快乐感。虽然离真正能用还有很长的距离,但能在一次次“听感灾难”中总结经验,逐渐把水下说话调教回“人话”,也是挺有成就感的一件事!
距离完成上一版 QFP v2 已过去半年。这段时间里,我逐步学习并吸收了多项新的技术细节,同时也回头重新审视了 v2 中的一些设计权衡。与其在原有版本上不断修修补补,不如彻底重新梳理思路,于是决定实现一个全新的 QFP v3。
这次重构的起点,其实源于对计算机浮点数表示方式的深入理解。之前在 v2 中,我采用的是对数和线性结合的 int8 量化方案,本质上是在人为模拟“越接近 0,量化越密集”的分布特性。但在真正理解 IEEE 浮点格式后,我意识到这种特性其实在浮点数中已经天然存在。
统计 MDCT 系数后可以看出,其整体大致服从拉普拉斯分布,大多数数值集中在 0 附近,仅有少量系数幅度较大。这与浮点数在靠近 0 的区域具备更高精度的特性非常契合。因此,在 v3 中我放弃了原来的 int8 方案,改为直接使用浮点数量化,并进一步利用非规格化数来增强极小数值的精度表现。
v2 中采用 int16 配合 LEB128 的方式进行长度标记,这在实验阶段尚可接受,但在面对更复杂、更多样的数据结构时显得有些笨重。
在 v3 中,我改为使用前缀码来表示长度,通过 0、10、110、111 这样的前缀组合,实现最多 4 字节的编码,覆盖范围从 0 到 536,870,911。这样的设计不仅在长度较短时更节省空间,也让解码过程更直接,可以实现完全无状态解码,为后续所有变长结构提供了统一的基础。
如果说 v2 的编码策略有什么明显特点,那就是所有数据统一使用 int8 精度。而在 v3 中,这一点被彻底改变。
在文件头中,我定义了整体的精度方案,支持 fp8、fp6、fp4 及 int2 组合使用。MDCT 系数会根据分组情况选用不同的精度,从而在码率和失真之间实现更灵活的平衡。
为支持这种设计,我实现了一套数组打包机制,用于封装不同精度的系数数据。由于现在每个分组都可能采用不同精度,如果按分组单独进行 RLE,连续性会被频繁打断,压缩率并不理想。因此在实际编码时,我将相同精度的分组系数拼接在一起,再统一执行 RLE,将其编码为整块数据。解码时再通过索引将数据还原到对应的分组。这种结构在实现上稍显复杂,但能显著提高 RLE 效率,整体逻辑依然清晰,也更有利于后续扩展。
引入可变精度后,v2 中那种内联式 RLE 编码已不再适用。在 v3 中,我将 RLE 独立为单独的数组,以 idx 和 len 的形式描述一个连续的游程。
不过,前缀码本身有固定开销,如果游程太短,反而会导致体积增大。因此在设计时,我明确规定:只有在当前精度下连续出现的 0 至少能构成三字节数据量时,才会触发 RLE 编码。例如在 fp4 精度下,需要连续出现至少 6 个 0 才会进行一次 RLE。这样,RLE 才真正成为一种具有收益的优化手段。
在 v2 中,我曾尝试在单帧 MDCT 内部进行分组并计算能量,而在 v3 中,这套机制被完整扩展为一个分组级别的控制系统。
现在,每个分组都拥有独立的精度方案、缩放系数以及能量损失预算。同时,我将 v2 中按排序删除 MDCT 系数的逻辑移植过来,并在此基础上引入了更细化的权重计算方式。
权重设计综合了多个因素:例如在频率维度上,高频分组本身对听感影响较小,因此可删除更多系数;在精度维度上,低精度分组因消耗不大,可适当提高权重;而在较高 qp 条件下,则会更倾向于保留低频成分。
此外,我还加入了左右声道之间的能量一致性检查,当左右声道能量差异较小时,可在不引入明显的空间感失真的情况下进一步删除系数。整体来看,当前的编码行为已非常接近恒定码率模式。
由于 v3 引入了分组级别的精度方案、缩放系数、能量损失预算等大量元数据,如果对所有分组一视同仁地进行描述,在低能量或被完全删除的分组上会产生相当可观的额外开销。随着分组数量增加,这种稠密存储元数据的方式会迅速拉高整体码率。
为了解决这个问题,v3 改用了稀疏的分组描述方式。我在帧数据的最前面引入了一个分组位图,用来标记哪些分组在当前帧中被启用。只有在位图中被标记为有效的分组,才会继续保存对应的精度参数、缩放系数以及后续的系数数据,其余分组则被视为完全丢弃,不再占用任何额外空间。
在实际编码流程中,每一帧在进入分组处理之前,都会先对各个分组的能量进行一次评估。如果某个分组的原始能量已经低于阈值,就会直接跳过该分组的后续处理,从而进一步节省空间。这里使用的是量化之前的原始能量,可以保证后续的噪声填充仍然基于正确的能量估计,不会因为在量化时丢弃信息而导致空洞。
通过分组位图和能量预筛选的结合,分组级别的复杂控制机制在保持听感稳定的同时,也将额外开销压缩到了一个可接受的范围内。
噪声填充的思路与 v2 基本一致,但实现方式发生了关键变化。v3 中所有噪声填充都严格按分组进行,不再出现系数权重与填充边界不对齐的问题。
这一改动有效避免了能量在频域中的泄漏,使得低频区域的稳定性明显优于 v2,整体听感也更为干净。
在 v2 时期,我曾提到 QFP 不像 MP3 那样具备同步头和容错机制,更像是一种裸数据格式。到了 v3,这一点终于得到补全。
在学习计算机网络相关内容后,我重新设计了帧封装方式,放弃了通过头部直接标明长度的方案,改用类 PPP 的字节填充式封装结构。这种设计使整个数据流具备自同步能力,即使发生少量读写错误,也能在后续重新对齐。
同时,每个帧头都加入了 CRC16 校验。一旦校验失败,解码器会直接输出全 0 帧,从而有效防止爆音和异常噪声。
在 v3 的文件头中,我加入了元数据支持,可存储曲目信息、图片等内容。这部分虽不影响音质,但它让 QFP 从一个纯粹的实验性编码格式,真正朝“可用音频格式”迈进了一步。
如果说 QFP v2 是一次验证想法可行性的实验,那么 QFP v3 则是一次系统性的工程化整理。从浮点数量化、可变精度体系,到分组能量控制、帧封装与容错机制,这一版本已具备可长期使用的基本框架。
它或许仍不算成熟,但至少不再只是一个概念,而是一个值得认真对待、可持续演进的音频编码方案。