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 能表示的范围,就会发生「削波」——声音出现刺耳的失真。
解决方案:
- 限幅(Clipping):超过最大值就截断为最大值。简单粗暴,但失真明显。
- 软限幅(Soft Clipping):用平滑曲线把超过阈值的部分「压」回来。音质更好。
- 动态范围压缩:先降低整体增益,再放大。专业音频软件常用。
// 简单的限幅处理(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 的三个核心参数和三大处理操作串在了一起。你从原始数据出发,根据需求调整采样率、音量、声道布局,最终得到目标音频。
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 这个「底片」理解透,后面的各种操作都是在这个底片上做文章。