为什么我有时可以使用 NodeJS 缓冲区连接音频数据,有时却不能?

Why can I sometimes concatenate audio data using NodeJS Buffers, and sometimes I cannot?

作为我正在进行的项目的一部分,需要将多个音频数据连接成一个大音频文件。音频文件由四个来源生成,各个文件存储在 Google 云存储桶中。每个文件都是一个 mp3 文件,很容易验证每个文件是否正确生成(我可以单独播放它们,用我喜欢的软件编辑它们等)。

为了将音频文件合并在一起,nodejs 服务器使用 axios POST 请求从 Google 云存储加载文件作为数组缓冲区。从那里开始,它使用 Buffer.from() 将每个数组缓冲区放入节点缓冲区,因此现在我们有一个缓冲区对象数组。然后它使用 Buffer.concat() 将 Buffer 对象连接成一个大 Buffer,然后我们将其转换为 Base64 数据并发送到客户端服务器。

这很酷,但在连接从不同来源生成的音频时会出现问题。我上面提到的 4 个来源是 Text to Speech 软件平台,例如 Google Cloud Voice 和 Amazon Polly。具体来说,我们有来自 Google Cloud Voice、Amazon Polly、IBM Watson 和 Microsoft Azure Text to Speech 的文件。本质上只有五个文本到语音的解决方案。同样,所有单独的文件都有效,但是当通过这种方法将它们连接在一起时,会产生一些有趣的效果。

当声音文件被连接起来时,声音数据可能会或不会包含在最终的声音文件中,这似乎取决于它们来自哪个平台。以下是基于我的测试的 'compatibility' table:

|------------|--------|--------|-----------|-----|
| Platform / | Google | Amazon | Microsoft | IBM |
|------------|--------|--------|-----------|-----|
| Google     | Yes    | No     | No        | No  |
|------------|--------|--------|-----------|-----|
| Amazon     |        | No     | No        | Yes |
|------------|--------|--------|-----------|-----|
| Microsoft  |        |        | Yes       | No  |
|------------|--------|--------|-----------|-----|
| IBM        |        |        |           | Yes |
|------------|--------|--------|-----------|-----|

效果如下:当我播放大输出文件时,它总是从包含的第一个声音文件开始播放。从那里开始,如果下一个声音文件兼容,就会听到它,否则会完全跳过(没有空声音或任何东西)。如果跳过,则该文件(例如 10 秒长的音频文件)的 'length' 将包含在生成的输出声音文件的末尾。但是,当我的音频播放器到达最后一个 'compatible' 音频播放点时,它会立即跳到结尾。

作为场景:

Input:
sound1.mp3 (3s) -> Google
sound2.mp3 (5s) -> Amazon
sound3.mp3 (7s)-> Google
sound4.mp3 (11s) -> IBM

Output:
output.mp3 (26s) -> first 10s is sound1 and sound3, last 16s is skipped.

在这种情况下,输出声音文件的长度为 26 秒。在前 10 秒,您会听到 sound1.mp3sound3.mp3 背靠背播放。然后在 10s 时(至少在 firefox 中播放这个 mp3 文件)播放器在 26s 时立即跳到结尾。

我的问题是:有人知道为什么有时我可以用这种方式连接音频数据,而有时却不能吗?输出文件末尾为什么会包含 'missing' 数据?如果它适用于某些情况,那么连接二进制数据不应该在所有情况下都有效吗,因为所有文件都有 mp3 编码?如果我错了,请告诉我如何成功连接任何 mp3 文件 :) 我可以提供我的nodeJS后端代码,但是使用的过程和方法上面有描述。

感谢阅读?

问题的潜在来源

采样率

44.1 kHz 通常用于音乐,因为它用于 CD 音频。 48 kHz 通常用于视频,因为它曾用于 DVD。这两个采样率都比语音所需的采样率高得多,因此您的各种文本到语音提供商输出的内容可能有所不同。 22.05 kHz(44.1 kHz 的一半)很常见,还有 11.025 kHz。

虽然每个帧都指定了自己的采样率,从而可以生成具有不同采样率的流,但我从未见过解码器尝试在流中切换采样率。我怀疑解码器正在跳过这些帧,或者甚至可能跳过任意块,直到它再次获得一致的数据。

使用 FFmpeg(或 FFprobe)之类的工具来计算文件的采样率:

ffmpeg -i sound2.mp3

你会得到这样的输出:

Duration: 00:13:50.22, start: 0.011995, bitrate: 192 kb/s
  Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s

在此示例中,44.1 kHz 是采样率。

频道数

我希望您的语音 MP3 是单声道的,但检查一下以确保无害。与上面一样,检查 FFmpeg 的输出。在我上面的例子中,它说 stereo.

与采样率一样,从技术上讲,每个帧都可以指定自己的通道数,但我不知道有哪个播放器会在中途切换通道数。因此,如果要串联,则需要确保所有通道数都相同。

ID3 标签

文件开头(ID3v2)and/or结尾(ID3v1)有ID3 metadata是很常见的。人们不太希望在中途获得这些数据。您需要确保在连接之前将此元数据全部删除。

MP3 位库

MP3 帧不一定是独立的。如果你有一个恒定的比特率流,编码器可能仍然使用较少的数据来编码一帧,而更多的数据来编码另一帧。发生这种情况时,某些帧包含其他帧的数据。这样,可以从额外带宽中获益的帧可以获得额外带宽,同时仍将整个流适合恒定比特率。这是"bit reservoir".

如果您剪切一个流并拼接在另一个流中,您可能会拆分一个帧及其依赖帧。这通常会导致音频故障,但也可能导致解码器向前跳过。一些表现不佳的解码器将完全停止播放。在你的例子中,你没有削减任何东西,所以这可能不是你的麻烦的根源......但我在这里提到它是因为它与你处理这些流的方式绝对相关。

另请参阅:http://wiki.hydrogenaud.io/index.php?title=Bit_reservoir

解决方案

选择 "normal" 格式,重新采样并重新编码不合格的文件

如果您的大部分来源都是完全相同的格式并且只有一两个未完成,您可以转换不符合要求的文件。从那里,从所有内容中剥离 ID3 标签并连接起来。

要进行转换,我建议将其作为 child process.

踢到 FFmpeg
child_process.spawn('ffmpeg' [
  // Input
  '-i', inputFile, // Use '-' to write to STDIN instead

  // Set sample rate
  '-ar', '44100',

  // Set audio channel count
  '-ac', '1',

  // Audio bitrate... try to match others, but not as critical
  '-b:a', '64k',

  // Ensure we output an MP3
  '-f', 'mp3',

  // Output
  outputFile // As with input, use '-' to write to STDOUT
]);

最佳解决方案:让 FFmpeg(或类似软件)为您完成工作

所有这些最简单、最可靠的解决方案是让 FFmpeg 为您构建一个全新的流。这将使您的音频文件被解码为 PCM,并生成一个新的流。您可以添加参数以对这些输入重新采样,并根据需要修改通道数。然后输出一个流。使用 concat filter.

这样,您可以接受任何类型的音频文件,您不必编写代码来将这些流整合在一起,一旦设置,您就不必担心了。

唯一的缺点是它需要对所有内容进行重新编码,这意味着又一代的质量丢失了。无论如何,这对于任何不合格的文件都是必需的,而且这只是演讲,所以我不会再考虑了。

@Brad 的回答就是解决方案!他建议的第一个解决方案奏效了。让 FFMpeg 正常工作花了一些时间,但最终使用 fluent-ffmpeg 库成功了。

我的每个文件都存储在 Google 云存储中,而不是服务器的硬盘上。这给 FFmpeg 带来了一些问题,因为它需要文件路径有多个文件,或者一个输入流(但只支持一个,因为只有一个 STDIN)。

一种解决方案是将文件暂时放在硬盘上,但这对我们的用例不起作用,因为我们可能会大量使用此功能,而硬盘会增加延迟。

因此,我们按照建议将每个文件加载到 ffmpeg 中以将其转换为标准化格式。这有点棘手,但最后将每个文件请求为一个流,使用该流作为 ffmpeg 的输入,然后使用 fluent-ffmpeg 的 pipe() 方法(returns 一个流)作为输出工作.

然后我们将事件侦听器绑定到此管道的 'data' 事件,并将数据推送到数组 (bufs.push(data)),并在流 'end' 上连接此数组使用 Buffer.concat(bufs),然后是 promise resolve。 然后,一旦所有请求承诺都得到解决,我们就可以确定 ffmpeg 已经处理了每个文件,然后像以前一样使用 Buffer.concat() 将这些缓冲区连接到所需的组中,转换为 base64 数据,并发送给客户端。

这很好用,现在它似乎能够处理 files/sources 我可以扔给它的所有组合!

结论:

问题的答案是mp3数据一定是编码方式不同(不同的声道​​,采样率等),通过ffmpeg加载并以'unified'的方式输出使得mp3数据兼容。

解决方案是分别处理 ffmpeg 中的每个文件,将 ffmpeg 输出通过管道传输到缓冲区,然后连接缓冲区。

感谢@Brad 的建议和详细的回答!