likes
comments
collection
share

我的 MP3 编码器之旅:从 LAME 到 Shine 的 Go 实现

作者站长头像
站长
· 阅读数 35

编者按:本文作者以轻松幽默的口吻,生动地向我们讲述了自己实现MP3编码的曲折历程。

起初,作者满怀信心地想利用LAME这个业界公认的最佳MP3编码器。然而种种问题让初心萌动的他不得不放弃。随后,作者又尝试自己从零开始搞一个编码器,却在复杂的编码算法面前退缩。在绝望中,作者终于发现了Shine这个“朴实无华”的编码器,于是“一见钟情”,决定移植一个Go语言版本。

接下来,作者描绘了将Shine从C语言移植到Go语言的曲折探索。好不容易编译成功,输出的文件又无法播放。作者没有放弃,终于找到cxgo这个自动转换工具。然而,转换后的代码仍需进行少量调整。

在阅读本文时,你几乎能够感同身受作者在实现目标时遇到的种种困难与挫折。但更可贵的是,作者从不放弃,保持积极乐观的心态,最终实现了自己的目标。无论目标大小,这种坚持不懈的精神都值得我们学习。相信阅读此文,你也会像编者一样,对MP3编码有了更深的认识。

一、我心目中最优秀的 MP3 音频编码器:LAME

说到 MP3 这种音频编码,有一款开源的编码器:LAME。因其提供极其丰富的功能,多年来一直以来受到软件和音频工程师的热烈欢迎。如果我们想将音频数据编码成具有专业性能和质量的 MP3,要么使用 LAME,要么使用基于它构建的工具

作为一名程序员,想要将音频数据编码为 MP3,我们可能会去寻找所在语言生态中的开源库包。让我们看看主流语言生态中的 MP3 编码器都有哪些:

一路看下来,似乎都离不开 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 文件中找到的这个很棒的图表:

我的 MP3 编码器之旅:从 LAME 到 Shine 的 Go 实现

请大家注意一个模块: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 编码器之旅:从 LAME 到 Shine 的 Go 实现

最后,我终于编译出了一些东西......但是它生成的 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 < 32 && (val >> 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 << bs->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 << bs->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 << uint64(bs.Cache_bits)
	} else {
		if bs.Data_position+int64(unsafe.Sizeof(uint64(0))) >= 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)<<8 | bs.Cache<<24
		bs.Data_position += int64(unsafe.Sizeof(uint64(0)))
		bs.Cache_bits = int64(32 - N)
		if N != 0 {
			bs.Cache = val << uint64(bs.Cache_bits)
		} else {
			bs.Cache = 0
		}
	}
}

五、过渡到纯 Go 库

最后一项任务是全局搜索代码,删除所有使用 cxgo/runtime/libc 的地方。我还会尝试删除unsafe包,因为安全总比不安全好,不是吗?

删除 libc 的工作很简单,只需将数学函数替换为相应的数学函数即可。而让库变得安全则更具挑战性。截至本文撰写时,库中还有一个开放的issue,即删除最后一次使用的 unsafe 包。只有一个issue了,你能解决吗?

没有 C 语言代码啦!任务完成!现在,Windows 用户可以用纯 Go 来编码 MP3 文件了。耶?