13、音频处理基础:PCM音量调节、采样率转换、声道混合、音频裁剪
各位同学,欢迎来到第十三章。前面我们搞定了视频采集和编码,但别忘了,一个完整的音视频作品,音频质量往往决定了用户的最终体验。我自己就踩过不少坑——画面清晰度拉满,结果声音一出来全是爆音,或者左右声道反了,那感觉,嗯,直接劝退用户。
这一章,咱们就深入音频处理的几个核心操作:音量调节、采样率转换、声道混合、音频裁剪。这些操作在Android开发中非常常见,比如做直播时的混音、录制后的音频编辑、或者适配不同播放设备。说白了,就是让你手里的PCM数据“听话”。
核心知识点一览:
- PCM音量调节:直接操作采样点数值,注意溢出
- 采样率转换:重采样算法,保持音调不变
- 声道混合:单声道转双声道、双声道混合为单声道
- 音频裁剪:按时间或采样点精确截取
13.1 PCM音量调节——别让声音“爆”了
音量调节,说白了就是调整每个采样点的幅值。PCM数据里,每个采样点都是一个数值(比如16位有符号整数,范围-32768到32767)。我们乘上一个增益系数,就能改变音量。
举个例子,原始采样点是10000,增益设为0.5,结果就是5000,音量变小。增益设为2.0,结果就是20000,音量变大。但这里有个坑——如果增益太大,比如3.0,10000 * 3 = 30000,虽然没超过32767,但如果是12000 * 3 = 36000,这就溢出了!
我曾经在做一个直播项目时,主播那边麦克风音量忽大忽小,我直接乘了个2.0的固定增益。结果高潮部分声音直接爆掉,用户反馈“耳朵要聋了”。后来我加上了clamp处理,再也没出过问题。
来看代码实现:
// 16位PCM音量调节
public byte[] adjustVolume(byte[] pcmData, float gain) {
short[] samples = new short[pcmData.length / 2];
ByteBuffer.wrap(pcmData).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(samples);
for (int i = 0; i < samples.length; i++) {
int adjusted = (int)(samples[i] * gain);
// 关键:clamp防止溢出
if (adjusted > 32767) adjusted = 32767;
if (adjusted < -32768) adjusted = -32768;
samples[i] = (short) adjusted;
}
byte[] result = new byte[pcmData.length];
ByteBuffer.wrap(result).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples);
return result;
}
个人习惯:我一般把增益范围控制在0.0到2.0之间。超过2.0,即使做了clamp,声音也会失真,听起来像“削波”了。如果确实需要更大音量,建议从采集端调整硬件增益。
13.2 采样率转换——让不同设备“说同一种语言”
采样率转换,就是把一种采样率的PCM数据变成另一种。最常见的场景:Android设备采集默认是44100Hz,但某些编码器要求48000Hz。你想想看,如果不转换,直接送进去,播放出来要么音调变高(采样率变低),要么变低(采样率变高),完全没法用。
最简单的算法是线性插值。比如从44100Hz转到48000Hz,相当于每441个原始采样点要生成480个新采样点。新采样点的值,由相邻两个原始采样点按比例计算得出。
// 简单的线性插值重采样(16位单声道)
public short[] resample(short[] input, int inputRate, int outputRate) {
double ratio = (double) outputRate / inputRate;
int outputLength = (int) (input.length * ratio);
short[] output = new short[outputLength];
for (int i = 0; i < outputLength; i++) {
double srcIndex = i / ratio;
int leftIndex = (int) srcIndex;
int rightIndex = Math.min(leftIndex + 1, input.length - 1);
double fraction = srcIndex - leftIndex;
// 线性插值
output[i] = (short) (input[leftIndex] * (1 - fraction) + input[rightIndex] * fraction);
}
return output;
}
注意:线性插值虽然简单,但高频成分会有损失。如果对音质要求高,建议用sinc插值或多相滤波器。我在做音乐类App时用过sinc插值,效果明显好一截,但计算量也大了不少。
13.3 声道混合——单声道变立体声?没那么简单
声道混合分两种情况:单声道转双声道和双声道转单声道。
单声道转双声道最简单——直接把单声道数据复制一份给右声道就行。但注意,这样做出来的“立体声”其实还是单声道内容,只是左右都响。真正的立体声需要左右声道有不同的内容。
双声道转单声道就有讲究了。最简单的做法是把左右声道采样点相加除以2。但这样会损失一些空间感。我在项目中遇到过一个问题:用户录制的视频是双声道,但播放设备只支持单声道,直接平均后,人声变得很闷。
我的经验:如果左右声道内容差异不大(比如手机录制),直接平均没问题。但如果左右声道差异明显(比如立体声录音),建议保留主要声道,或者用加权平均(左0.6 + 右0.4)。
// 双声道转单声道(16位,交错存储)
public short[] stereoToMono(short[] stereo) {
int monoLength = stereo.length / 2;
short[] mono = new short[monoLength];
for (int i = 0; i < monoLength; i++) {
int left = stereo[i * 2];
int right = stereo[i * 2 + 1];
// 平均,注意溢出
mono[i] = (short) ((left + right) / 2);
}
return mono;
}
// 单声道转双声道(交错存储)
public short[] monoToStereo(short[] mono) {
short[] stereo = new short[mono.length * 2];
for (int i = 0; i < mono.length; i++) {
stereo[i * 2] = mono[i]; // 左声道
stereo[i * 2 + 1] = mono[i]; // 右声道
}
return stereo;
}
13.4 音频裁剪——精确到采样点的“剪刀手”
音频裁剪,就是截取PCM数据中的一段。听起来简单,但要注意几个细节。
首先,裁剪单位是采样点,不是字节。16位PCM每个采样点占2字节,所以裁剪时字节索引要乘以2。其次,如果是双声道,每个采样点包含左右两个声道值,裁剪时要成对处理。
举个例子,我想从第10秒开始,裁剪5秒的音频。假设采样率44100Hz,单声道16位:
- 起始采样点 = 10 * 44100 = 441000
- 结束采样点 = 15 * 44100 = 661500
- 起始字节 = 441000 * 2 = 882000
- 裁剪长度(字节)= (661500 - 441000) * 2 = 441000字节
// 按时间裁剪PCM(16位单声道)
public byte[] cropPcmByTime(byte[] pcmData, int sampleRate,
float startSec, float durationSec) {
int startSample = (int) (startSec * sampleRate);
int endSample = (int) ((startSec + durationSec) * sampleRate);
int bytesPerSample = 2; // 16位
int startByte = startSample * bytesPerSample;
int length = (endSample - startSample) * bytesPerSample;
// 边界检查
if (startByte < 0 || startByte + length > pcmData.length) {
throw new IllegalArgumentException("裁剪范围超出数据长度");
}
byte[] result = new byte[length];
System.arraycopy(pcmData, startByte, result, 0, length);
return result;
}
我曾经在做一个音频编辑功能时,用户反馈裁剪后播放有“咔哒”声。排查了半天,发现是裁剪点没有对齐到采样点边界。比如16位PCM,如果从奇数字节开始裁剪,就会破坏采样点的完整性。所以一定要确保起始字节是2的倍数。
13.5 综合实战:一个简单的音频处理工具
把上面四个功能组合起来,就能做一个简单的音频处理工具。比如用户上传一段录音,先裁剪出有效部分,再调节音量,最后转换采样率。我习惯把这些操作封装成一个链式调用:
public class AudioProcessor {
private byte[] pcmData;
private int sampleRate;
private int channels;
public AudioProcessor(byte[] pcmData, int sampleRate, int channels) {
this.pcmData = pcmData;
this.sampleRate = sampleRate;
this.channels = channels;
}
public AudioProcessor crop(float startSec, float durationSec) {
// 先转成short数组,方便处理多声道
short[] samples = byteToShort(pcmData);
int startSample = (int) (startSec * sampleRate);
int endSample = (int) ((startSec + durationSec) * sampleRate);
short[] cropped = Arrays.copyOfRange(samples, startSample * channels,
endSample * channels);
this.pcmData = shortToByte(cropped);
return this;
}
public AudioProcessor adjustVolume(float gain) {
short[] samples = byteToShort(pcmData);
for (int i = 0; i < samples.length; i++) {
int adjusted = (int) (samples[i] * gain);
adjusted = Math.max(-32768, Math.min(32767, adjusted));
samples[i] = (short) adjusted;
}
this.pcmData = shortToByte(samples);
return this;
}
public AudioProcessor resample(int targetRate) {
// 这里调用之前写的resample方法
short[] samples = byteToShort(pcmData);
short[] resampled = resample(samples, this.sampleRate, targetRate);
this.pcmData = shortToByte(resampled);
this.sampleRate = targetRate;
return this;
}
public byte[] getResult() {
return pcmData;
}
// 辅助方法:byte[]与short[]互转
private short[] byteToShort(byte[] data) { /* 略 */ }
private byte[] shortToByte(short[] data) { /* 略 */ }
}
使用示例:
AudioProcessor processor = new AudioProcessor(rawPcm, 44100, 1);
byte[] result = processor
.crop(5.0f, 10.0f) // 裁剪5-15秒
.adjustVolume(1.5f) // 音量提升50%
.resample(48000) // 转为48kHz
.getResult();
好了,这一章的内容就到这里。音频处理看似基础,但每个细节都影响最终效果。特别是溢出和边界问题,我建议你在写代码时多写几个单元测试,把各种极端情况都覆盖到。嗯,下一章我们会把这些处理应用到实际的录制和推流流程中,到时候你就知道这些基础操作有多重要了。
公众号:蓝海资料掘金营,微信deep3321