编者按:本文作者以轻松幽默的口吻,生动地向我们讲述了自己实现MP3编码的曲折历程。
起初,作者满怀信心地想利用LAME这个业界公认的最佳MP3编码器。然而种种问题让初心萌动的他不得不放弃。随后,作者又尝试自己从零开始搞一个编码器,却在复杂的编码算法面前退缩。在绝望中,作者终于发现了Shine这个“朴实无华”的编码器,于是“一见钟情”,决定移植一个Go语言版本。
接下来,作者描绘了将Shine从C语言移植到Go语言的曲折探索。好不容易编译成功,输出的文件又无法播放。作者没有放弃,终于找到cxgo这个自动转换工具。然而,转换后的代码仍需进行少量调整。
在阅读本文时,你几乎能够感同身受作者在实现目标时遇到的种种困难与挫折。但更可贵的是,作者从不放弃,保持积极乐观的心态,最终实现了自己的目标。无论目标大小,这种坚持不懈的精神都值得我们学习。相信阅读此文,你也会像编者一样,对MP3编码有了更深的认识。
本文由丘山子翻译,原文链接:braheezy.github.io/posts/what-… ,原文作者:Michael Braha。
一、我心目中最优秀的 MP3 音频编码器:LAME
说到 MP3 这种音频编码,有一款开源的编码器:LAME。因其提供极其丰富的功能,多年来一直以来受到软件和音频工程师的热烈欢迎。如果我们想将音频数据编码成具有专业性能和质量的 MP3,要么使用 LAME,要么使用基于它构建的工具。
作为一名程序员,想要将音频数据编码为 MP3,我们可能会去寻找所在语言生态中的开源库包。让我们看看主流语言生态中的 MP3 编码器都有哪些:
- 开发界备受赞誉的 FFmpeg:这是一款无处不在的视频和音频处理程序。相信你一定观看过 YouTube 中的视频,这就是 ffmpeg 处理过的内容。FFmpeg 通过使用 LAME进行 MP3 音频格式的编码。
- Python:
- LAME bindings:它为 libmp3lame C 库提供了 Python API。因此需要安装 libmp3lame 才能运行。
- PyAudio:它也是利用 libmp3lame 进行 MP3 音频格式的编码。
- Rust:LAME bindings,但是是 Rust 版本的。
- Java:LAME原生支持。
- Go:更多的 LAME bindings。(译者注:"LAME bindings" 的意思是将 LAME 库与其他编程语言或工具集成的接口。)
- JavaScript:基于LAME 的 jump3r-code Java 的port进行开发的。 (对于非程序员来说,port就是用另一种语言编写的软件。)
一路看下来,似乎都离不开 LAME。
在之前我尝试将 Quite OK Audio (QOA) 格式的文件转换为各种音频格式时,我感到非常有趣,这是一种任何工具或库都不支持的格式。这个过程包括将 .qoa 文件转换为 .mp3。
在一个黑云密布、风雨交加的夜晚,我第一次遇见了 LAME。go-lame 项目为 LAME 提供了 Go 语言的bindings。对于我编写的所有 Go 项目,我都希望它们能够支持 Linux、Mac 和 Windows 平台,但 go-lame 没有明确说明它将支持 Windows 平台。这就需要我来做了!也许 ChatGPT 也可以帮忙。
无论我尝试使用 C 语言跨平台编译器、原生 Windows 环境,还是那些声称捆绑了所有工具的、能够构建一切的容器,我都无法将 go-lame 构建为 Windows 版本。
恼羞成怒之下,我放弃了继续开发 QOA 程序,以让其支持 Windows 上的 MP3格式。但这给我留下了不好的印象......如果我有一个不依赖 LAME 的 Go 库就好了。也许我应该写一个?
二、自己手搓一个MP3编码器?
于是我充满自信地开始深入研究 MP3 音频格式的原理。我刚开始觉得这有什么难的?
随着对其的深入了解,我得出一个结论:非常难。
没有类似 MPEG-1 的标准规定编码器应该如何工作。只有关于文件格式和 MP3 比特流编码结构的官方规范。这使得解码器的行为都是一样的,但编码器如何创建比特流则取决于作者。这就导致了 90 年代到 00 年代的编码质量参差不齐。
描述实现编码器所需的细节的相关文档需要付费获取。这种隐藏和封锁知识的做法令人反感,而且会扼杀大家对其的兴趣和进一步创新。
在整个 90 年代和 00 年代,MP3 的基础编码/解码技术一直处于专利保护之下。直到 2017 年,它才在美国失去专利。像我这样的开源开发者和业余开发者会避免任何涉及专利的东西。
MP3 使用的算法非常复杂。看看我从 PDF 文件中找到的这个很棒的图表:
请大家注意一个模块:psycho-acoustic model。除了这是一个非常酷的词之外,还意味着编码算法将分析音频波的特征,并丢弃人类无法听到的部分。举个简单的例子,人类只能听到 20 Hz 和 20 kHz 之间的频率,因此要去掉高于和低于这些频率的部分。
如果你想了解更多关于 MP3 编码算法的细节,这个 PDF 是我找到的最好的学习文档。
考虑到我不会从头开始实现一个新的 MP3 编码器,我接下来考虑将 C LAME 库移植到 Go。我打开了一个 shell 来检查这样做是否合理,并运行了 cloc 命令来计算项目中的代码行数:
$ cloc .
62 text files.
62 unique files.
11 files ignored.
github.com/AlDanial/cloc v 1.90 T=0.04 s (1381.9 files/s, 735490.1 lines/s)
------------------------------------------------------------------------------
Language files blank comment code
------------------------------------------------------------------------------
C 21 3130 3427 16610
C/C++ Header 24 483 726 1670
Bourne Shell 1 64 224 503
make 3 47 14 164
Windows Resource File 1 3 1 46
IDL 1 1 0 31
------------------------------------------------------------------------------
SUM: 51 3728 4392 19024
------------------------------------------------------------------------------
16,000 行晦涩难懂的 C 语言代码,充斥着指针“魔法和神秘的内存操作?不用了,谢谢!
三、其他 MP3 编码器能否大放异彩?
我必须要明白,在这里我只是为了解决这个 QOA 程序中的一个小错误。支持 MP3 并不是特别重要。我需要一条通往成功的捷径。我并不需要什么高级的 MP3 编码器。
LAME 很友好地提到了其他编码器,并尽可能地吹嘘了他们的优秀表现(我也认为他们应该这样做!)。编码器列表中的一项引起了我的注意:
Shine 是由 LAME 的 Gabriel Bouvigne 制作的一个没有什么特色但干净易读的 MP3 编码器。作为一款入门或学习的工具,它非常不错。也可能也是唯一一款开源的、使用定点数学运算的MP3 编码器。(译者注:在计算机中,浮点数运算比定点数运算更精确,但是定点数运算更快,因为它们不需要使用浮点处理器。因此,对于某些应用程序,如嵌入式系统或低功耗设备,使用定点数学运算可能更为合适。 )
这正是我要寻找的:
- 开源
- 没有特色,即不过于复杂
- 可读性强
- 对新手十分友好
通过快速的互联网搜索,我找到了 Shine 的现代版本。Shine 是一个相对较小的项目,代码量比 LAME 少得多,因此它更容易理解和移植。
四、尝试:将 C 代码转换为 Go 代码
我进行了两次认真的尝试。虽然很遗憾,但这确实让我对整个程序有了更好的理解。
在第一次尝试时,我坐下来,一个文件一个文件地把每个 C 语言函数转换为对应的 Go 语言代码。我得承认,很多东西我都是直接扔给 ChatGPT 让它转换的。下面是一个转换示例:
最后,我终于编译出了一些东西......但是它生成的 MP3 文件比我对比的 MP3 参考文件大了 5 倍。音频播放器拒绝播放它们,甚至告诉我文件格式不正确。为此,我花了几天时间去排除故障,但根据我的输出文件,我感觉我偏离了正确的方向。绝望之余,我在网上搜索了如何进行 C to Go ...
然后找到了 cxgo!它声称可以将 C 转换为 Go。尽管这款工具有实验性警告,但它还是给了我一线希望,于是我很快下载了它。我在一个小的 C 语言文件上进行了测试,结果看起来不错。我把它扔给整个 Shine 库,出乎意料的是,没有出现任何错误!我在几秒钟内得到了一个完整的 Go 程序(这就是第一次尝试失败的痛苦所在)。
以下是一个示例。这段 C 代码如下:
/*
* shine_putbits:
* --------
* write N bits into the bit stream.
* bs = bit stream structure
* val = value to write into the buffer
* N = number of bits of val
*/
void shine_putbits(bitstream_t *bs, unsigned int val, unsigned int N) {
#ifdef DEBUG
if (N > 32)
printf("Cannot write more than 32 bits at a time.n");
if (N > N) != 0)
printf("Upper bits (higher than %d) are not all zeros.n", N);
#endif
if (bs->cache_bits > N) {
bs->cache_bits -= N;
bs->cache |= val cache_bits;
} else {
if (bs->data_position + sizeof(unsigned int) >= bs->data_size) {
bs->data = (unsigned char *)realloc(bs->data,
bs->data_size + (bs->data_size / 2));
bs->data_size += (bs->data_size / 2);
}
N -= bs->cache_bits;
unsigned int shift = val >> N;
bs->cache |= shift;
#ifdef SHINE_BIG_ENDIAN
*(unsigned int *)(bs->data + bs->data_position) = bs->cache;
#else
*(unsigned int *)(bs->data + bs->data_position) = SWAB32(bs->cache);
#endif
bs->data_position += sizeof(unsigned int);
bs->cache_bits = 32 - N;
if (N != 0)
bs->cache = val cache_bits;
else
bs->cache = 0;
}
}
转换为Go语言代码后:
import (
"github.com/gotranspile/cxgo/runtime/libc"
"unsafe"
)
func shine_putbits(bs *bitstream_t, val uint64, N uint64) {
if uint64(bs.Cache_bits) > N {
bs.Cache_bits -= int64(N)
bs.Cache |= val = bs.Data_size {
bs.Data = (*uint8)(libc.Realloc(unsafe.Pointer(bs.Data), int(bs.Data_size+bs.Data_size/2)))
bs.Data_size += bs.Data_size / 2
}
N -= uint64(bs.Cache_bits)
bs.Cache |= val >> N
*(*uint64)(unsafe.Pointer((*uint8)(unsafe.Add(unsafe.Pointer(bs.Data), bs.Data_position)))) = (bs.Cache >> 24) | ((bs.Cache >> 8) & 0xFF00) | (bs.Cache&0xFF00)