8、音频处理基础:PCM数据格式、音频重采样、音量调节、声道转换

各位好,欢迎来到第八章。

前面几章我们把视频相关的硬骨头啃得差不多了。从这一章开始,咱们正式进入音频的世界。说实话,音频处理在很多人眼里是「玄学」,但其实它的基础比视频要纯粹得多。

我个人习惯把音频处理比作「玩水」。PCM 数据就是原始的水流,重采样是改变水流粗细,音量调节是控制水压,声道转换则是改变水管布局。嗯,这么一想是不是好理解多了?

8.1 PCM 数据格式:最原始的音频「底片」

PCM,全称 Pulse Code Modulation,脉冲编码调制。说白了,就是把连续的声波信号,每隔一小段时间「咔嚓」拍一张快照,然后把这个快照的数值记录下来。

你手机麦克风录下来的原始数据,绝大多数就是 PCM。它没经过任何压缩,所以体积巨大,但信息最完整。

8.1.1 三个核心参数

描述一段 PCM 数据,你需要知道三件事:

  • 采样率(Sample Rate):每秒拍多少张快照。单位 Hz。常见的有 8000Hz(电话)、44100Hz(CD)、48000Hz(视频常用)。
  • 位深(Bit Depth):每张快照用多少比特来记录。常见 8bit、16bit、24bit、32bit。位深越大,动态范围越宽,声音越细腻。
  • 声道数(Channels):同时有几路声音。1 是单声道,2 是立体声,5.1 是环绕声。

一个重要的公式

PCM 数据率(bps)= 采样率 × 位深 × 声道数

举个例子:CD 音质(44100Hz × 16bit × 2ch)= 1,411,200 bps ≈ 1.4 Mbps。一张 700MB 的 CD 只能存大约 70 分钟的未压缩音频。

8.1.2 数据存储方式

PCM 数据在内存里是怎么排布的?这里有个小坑,我刚开始做音频开发时踩过。

对于立体声(2 声道),常见两种布局:

  • 交错模式(Interleaved):L R L R L R ... 左右声道数据交替排列。这是最常用的方式,比如 Android 的 AudioTrack 默认就吃这种格式。
  • 平面模式(Planar):L L L L ... R R R R ... 左声道数据全部排完,再排右声道。FFmpeg 内部很多滤波器喜欢用这种。

我的经验:在 Android 上做实时音频处理,尽量用交错模式。因为 AudioRecord 和 AudioTrack 原生支持,省去一次数据重排的开销。平面模式虽然在某些算法(如 FFT 分析)上更方便,但多一次 memcpy 就是多一次性能损耗。

8.1.3 字节序问题

16bit 的 PCM 数据,一个采样点占 2 个字节。这两个字节谁在前谁在后?

  • 小端序(Little-Endian):低字节在前。x86 架构、Android 手机默认都是小端。
  • 大端序(Big-Endian):高字节在前。网络传输、某些嵌入式设备用大端。

我曾经在对接一个蓝牙耳机的音频数据时,发现声音全是「嘶嘶」的噪声。排查了半天,最后发现是字节序搞反了。嗯,从那以后我每次处理 PCM 都会先确认字节序。

8.2 音频重采样:改变声音的「颗粒度」

重采样,就是把一段音频从一种采样率转换成另一种采样率。比如你有一个 44100Hz 的音频,但你的播放设备只支持 48000Hz,那就得重采样。

8.2.1 为什么需要重采样?

  • 设备兼容性:不同硬件支持的采样率不同。
  • 格式统一:多路音频混音时,必须统一采样率。
  • 降低计算量:某些算法在低采样率下跑得更快。

8.2.2 重采样的核心原理

重采样本质上是一个「插值」过程。把原始采样点看作离散的点,通过某种算法「猜」出两个点之间的值。

常见的重采样算法,按质量从低到高排列:

算法 质量 性能 适用场景
最近邻插值 极差 极快 几乎不用,有严重锯齿噪声
线性插值 较差 实时性要求极高,对音质不敏感
三次样条插值 中等 中等 一般场景够用
Sinc 插值(带窗) 优秀 专业音频处理,如 libsamplerate

注意:重采样不是简单的「每隔 N 个点取一个」。降采样(从高到低)必须先做低通滤波,否则会产生「混叠失真」。升采样(从低到高)则要做插值和滤波。我见过有人直接跳点做降采样,结果高频全是「吱吱」的镜像噪声。

8.2.3 Android 上的重采样实践

在 Android 上,最简单的重采样方式是利用 AudioRecord 和 AudioTrack 的采样率参数。但如果你需要更精细的控制,推荐用 MediaCodec 或者第三方库如 libsamplerate(通过 JNI 调用)。

// 伪代码:用 AudioTrack 播放时指定采样率
int sampleRateInHz = 48000; // 目标采样率
int channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
int bufferSize = AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat);

AudioTrack audioTrack = new AudioTrack.Builder()
        .setAudioAttributes(new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .build())
        .setAudioFormat(new AudioFormat.Builder()
                .setEncoding(audioFormat)
                .setSampleRate(sampleRateInHz)
                .setChannelMask(channelConfig)
                .build())
        .setBufferSizeInBytes(bufferSize)
        .build();

// 注意:这里传入的 PCM 数据必须是 48000Hz 的
// 如果你的原始数据是 44100Hz,需要先重采样
audioTrack.write(pcmData, 0, pcmData.length);

8.3 音量调节:别小看这个「乘法」

音量调节,从数学上看就是一个乘法:output_sample = input_sample * gain。但实际做起来,坑不少。

8.3.1 线性增益 vs 分贝增益

用户调节音量时,通常用的是「分贝(dB)」单位。但底层处理时,我们用的是线性增益。

转换公式:

  • 线性增益 → 分贝:dB = 20 * log10(gain)
  • 分贝 → 线性增益:gain = 10^(dB / 20)

举个例子:用户把音量调低 6dB,对应的线性增益是 0.5。也就是说,每个采样点的值要乘以 0.5。

8.3.2 整数 PCM 的溢出问题

这是个大坑。16bit PCM 的取值范围是 -32768 到 32767。如果你把增益设为 2.0,一个原本是 20000 的采样点会变成 40000,但 40000 超出了 16bit 能表示的范围,就会发生「削波」——声音出现刺耳的失真。

解决方案

  1. 限幅(Clipping):超过最大值就截断为最大值。简单粗暴,但失真明显。
  2. 软限幅(Soft Clipping):用平滑曲线把超过阈值的部分「压」回来。音质更好。
  3. 动态范围压缩:先降低整体增益,再放大。专业音频软件常用。
// 简单的限幅处理(16bit PCM)
short sample = ...; // 原始采样点
float gain = 2.0f;
float result = sample * gain;

// 限幅
if (result > 32767.0f) {
    result = 32767.0f;
} else if (result < -32768.0f) {
    result = -32768.0f;
}

short outputSample = (short) result;

我的建议:在 Android 上做音量调节,优先用 AudioTrack.setVolume() 或者 MediaPlayer.setVolume()。这些 API 底层已经处理好了增益和限幅。除非你需要做自定义的淡入淡出效果,否则别自己手写乘法。

8.4 声道转换:重新排列声音的「位置」

声道转换,就是把音频从一种声道布局变成另一种。比如把立体声变成单声道,或者把 5.1 环绕声变成立体声。

8.4.1 常见的声道转换场景

  • 立体声 → 单声道:左右声道相加除以 2。注意不要溢出。
  • 单声道 → 立体声:复制一份数据到右声道。或者左声道原样,右声道延迟几个采样(模拟立体感)。
  • 5.1 → 立体声:需要按照特定系数混合。比如左声道 = 左前 * 0.5 + 左后 * 0.3 + 中置 * 0.2。

8.4.2 立体声转单声道的正确姿势

很多人觉得就是 (L + R) / 2。但这里有个细节:

// 交错模式下的立体声转单声道
short[] stereoData = ...; // L R L R L R ...
int frameCount = stereoData.length / 2;
short[] monoData = new short[frameCount];

for (int i = 0; i < frameCount; i++) {
    int left = stereoData[i * 2];
    int right = stereoData[i * 2 + 1];
    // 注意:用 int 做加法,防止溢出
    int sum = left + right;
    monoData[i] = (short) (sum / 2);
}

你可能会问:为什么不直接用 (left + right) >> 1?因为 Java 的右移是带符号的,负数右移会出问题。用除法更安全。

8.4.3 声道映射矩阵

对于复杂的声道转换(比如 5.1 到立体声),最好用矩阵乘法。每个输出声道是多个输入声道的线性组合。

举个例子,5.1 到立体声的简化映射:

左输出 = 左前 × 0.5 + 左后 × 0.3 + 中置 × 0.2 + 低音 × 0.1

右输出 = 右前 × 0.5 + 右后 × 0.3 + 中置 × 0.2 + 低音 × 0.1

这些系数不是随便定的,ITU-R BS.775 标准里有推荐值。

8.5 本章知识体系

下面这张图把本章的核心知识点串起来了。你想想看,从原始 PCM 数据到最终可播放的音频,中间经历了什么?

音频处理基础:知识体系 PCM 原始数据 采样率 (Hz) 位深 (bit) 声道数 重采样 改变采样率 音量调节 线性增益 / 分贝 声道转换 立体声/单声道/5.1 ⚠️ 避坑指南 • 重采样必须先做低通滤波,防止混叠失真 • 音量调节注意整数溢出和削波失真 • 声道转换注意系数矩阵和相位对齐

这张图把 PCM 的三个核心参数和三大处理操作串在了一起。你从原始数据出发,根据需求调整采样率、音量、声道布局,最终得到目标音频。

8.6 实战中的一些碎碎念

最后,分享几个我在项目中踩过的坑,希望能帮你省点时间:

  • 关于 PCM 的字节对齐:有些硬件要求 PCM 数据按 2 字节或 4 字节对齐。如果你传的数据不对齐,可能会崩溃或者出噪声。我遇到过蓝牙 A2DP 播放时,因为 buffer 没对齐导致声音断断续续。
  • 关于重采样库的选择:Android 自带的 AudioRecord 重采样质量一般。如果你做专业音频 App,建议用 libsamplerate 或者 soxr。我自己的项目里用 libsamplerate 的 Sinc 模式,音质明显好一截。
  • 关于音量调节的精度:如果你用 float 做增益计算,最后转回 short 时要注意四舍五入。直接强转 (short) 会截断小数部分,导致音量偏小。加 0.5f 再转会好一些。

一个小技巧:调试 PCM 数据时,可以把数据保存成 .raw 文件,然后用 Audacity 导入(File → Import → Raw Data)。设置好采样率、位深、声道数,就能直接看到波形。这比盯着十六进制看直观多了。

好了,这一章的内容就到这里。音频处理的基础说难不难,说简单也不简单。关键是把 PCM 这个「底片」理解透,后面的各种操作都是在这个底片上做文章。

公众号:蓝海资料掘金营,微信deep3321