20. 音频处理:跨平台音频采集与播放、PortAudio库封装、音频格式转换
音频处理这块,说实话,是跨平台开发里比较“磨人”的一个领域。为什么?因为底层API差异太大了。Windows上有WASAPI、DirectSound、MME,macOS/iOS有CoreAudio,Linux上有ALSA、PulseAudio、JACK。你想想看,如果每个平台都写一套采集和播放的代码,那维护成本得多高?
我个人习惯是,遇到这种底层差异大的场景,直接找一个成熟的跨平台音频库来封装。PortAudio就是我的首选。它把底层音频API都抽象了,你只需要关心采样率、声道数、缓冲区大小这些参数就行。今天我们就来聊聊怎么用C++把PortAudio封装成一套好用的接口,顺便把音频格式转换也搞定。
20.1 音频基础概念回顾
在动手之前,有几个概念得先理清楚。不然写代码的时候容易懵。
| 概念 | 说明 |
|---|---|
| 采样率 (Sample Rate) | 每秒采集的样本数,单位Hz。常见的有44100、48000、96000。 |
| 位深度 (Bit Depth) | 每个样本用多少位表示。常见的有16位、24位、32位浮点。 |
| 声道数 (Channels) | 单声道(1)、立体声(2)、5.1声道(6)等。 |
| 缓冲区大小 (Buffer Size) | 每次回调处理的样本帧数。越小延迟越低,但CPU压力越大。 |
| 音频帧 (Frame) | 一个帧包含所有声道的一个样本。比如立体声,一帧就是两个样本。 |
嗯,这里要注意:缓冲区大小不是随便设的。设太小了,音频会断断续续(xrun);设太大了,延迟高得没法用。我在项目中遇到过,把缓冲区设成64帧,结果在树莓派上跑直接爆音。后来调到256帧才稳定下来。
20.2 PortAudio库的封装思路
PortAudio本身是C语言写的,回调函数也是C风格的。直接用的话,代码会显得很“原始”。我的做法是,用C++的RAII和回调机制把它包一层,让使用者只需要继承一个接口类,实现几个虚函数就行。
核心封装点有三个:
- 设备管理:枚举输入输出设备,选择默认设备或指定设备。
- 流管理:打开、启动、停止、关闭音频流。
- 回调封装:把C回调转换成C++虚函数调用。
下面是我常用的一个封装骨架:
// AudioStream.h
class AudioStream {
public:
virtual ~AudioStream() = default;
// 子类实现这个回调
virtual int OnAudioCallback(const void* inputBuffer,
void* outputBuffer,
unsigned long framesPerBuffer,
const PaStreamCallbackTimeInfo* timeInfo,
PaStreamCallbackFlags statusFlags) = 0;
bool Open(PaDeviceIndex inputDevice, PaDeviceIndex outputDevice,
double sampleRate, unsigned long framesPerBuffer);
bool Start();
bool Stop();
void Close();
private:
PaStream* stream_ = nullptr;
// 静态C回调,转发到虚函数
static int PaCallback(const void* input, void* output,
unsigned long frameCount,
const PaStreamCallbackTimeInfo* timeInfo,
PaStreamCallbackFlags statusFlags,
void* userData);
};
这里有个关键点:PaCallback是静态函数,通过userData参数把this指针传进去,再调用虚函数。这样既保留了C回调的高效,又享受了C++的多态。
std::cout调试,结果声音像卡带一样,排查了半天才发现是IO阻塞了。
20.3 跨平台音频采集实战
采集音频,说白了就是把麦克风的数据读出来。PortAudio里,你只需要在回调函数里处理inputBuffer就行。
下面是一个采集类示例:
class AudioRecorder : public AudioStream {
public:
AudioRecorder() : buffer_(1024 * 1024) {} // 1MB环形缓冲区
int OnAudioCallback(const void* inputBuffer, void* outputBuffer,
unsigned long framesPerBuffer,
const PaStreamCallbackTimeInfo* timeInfo,
PaStreamCallbackFlags statusFlags) override {
if (inputBuffer != nullptr) {
// 把采集到的数据拷贝到环形缓冲区
size_t bytes = framesPerBuffer * channels_ * sizeof(float);
WriteToRingBuffer(inputBuffer, bytes);
}
return paContinue;
}
std::vector<float> GetRecordedData() {
// 从环形缓冲区读取数据
return ReadFromRingBuffer();
}
private:
std::vector<char> buffer_;
size_t writePos_ = 0;
size_t readPos_ = 0;
int channels_ = 2;
};
为什么用环形缓冲区?因为回调是高频触发的,而主线程读取数据是低频的。环形缓冲区可以无锁地处理这种生产-消费模式。当然,如果你需要更精细的控制,可以用std::atomic来保护读写指针。
std::vector,结果程序时不时崩溃,后来才发现是线程安全问题。
20.4 跨平台音频播放实战
播放和采集是对称的。采集是读inputBuffer,播放是写outputBuffer。
播放类的核心逻辑:
class AudioPlayer : public AudioStream {
public:
void LoadAudioData(const std::vector<float>& data, int channels) {
audioData_ = data;
channels_ = channels;
playPos_ = 0;
}
int OnAudioCallback(const void* inputBuffer, void* outputBuffer,
unsigned long framesPerBuffer,
const PaStreamCallbackTimeInfo* timeInfo,
PaStreamCallbackFlags statusFlags) override {
float* out = static_cast<float*>(outputBuffer);
unsigned long framesAvailable = (audioData_.size() / channels_) - playPos_;
if (framesAvailable >= framesPerBuffer) {
// 有足够数据,正常播放
memcpy(out, &audioData_[playPos_ * channels_],
framesPerBuffer * channels_ * sizeof(float));
playPos_ += framesPerBuffer;
} else {
// 数据不够,填充静音
memset(out, 0, framesPerBuffer * channels_ * sizeof(float));
return paComplete;
}
return paContinue;
}
private:
std::vector<float> audioData_;
size_t playPos_ = 0;
int channels_ = 2;
};
这里有个细节:当数据播放完时,返回paComplete而不是paContinue。这样PortAudio会自动停止流,避免一直输出静音。如果你返回paAbort,则会立即终止,连缓冲区里的数据都不播了。
20.5 音频格式转换
音频格式转换,说白了就是不同位深度、采样率、声道数之间的互相转换。PortAudio本身不提供转换功能,需要我们自己实现或者用第三方库(比如libsamplerate、soxr)。
常见的转换场景:
- 位深度转换:16位整数 ↔ 32位浮点
- 采样率转换:44100 ↔ 48000
- 声道重映射:立体声 ↔ 单声道
下面是一个16位整数转32位浮点的函数:
void ConvertInt16ToFloat(const int16_t* input, float* output, size_t numSamples) {
const float kScale = 1.0f / 32768.0f;
for (size_t i = 0; i < numSamples; ++i) {
output[i] = input[i] * kScale;
}
}
反过来,浮点转16位整数:
void ConvertFloatToInt16(const float* input, int16_t* output, size_t numSamples) {
for (size_t i = 0; i < numSamples; ++i) {
// 钳位到[-1.0, 1.0]
float sample = input[i];
if (sample > 1.0f) sample = 1.0f;
if (sample < -1.0f) sample = -1.0f;
output[i] = static_cast<int16_t>(sample * 32767.0f);
}
}
采样率转换就复杂一些。最简单的办法是线性插值,但音质一般。我推荐用libsamplerate(也叫Secret Rabbit Code),它提供了高质量的采样率转换算法。封装起来也不复杂:
class SampleRateConverter {
public:
SampleRateConverter(int srcRate, int dstRate, int channels)
: srcRate_(srcRate), dstRate_(dstRate), channels_(channels) {
srcState_ = src_new(SRC_SINC_BEST_QUALITY, channels, &error_);
if (!srcState_) {
throw std::runtime_error("Failed to create SRC state");
}
}
~SampleRateConverter() {
if (srcState_) src_delete(srcState_);
}
std::vector<float> Process(const std::vector<float>& input) {
SRC_DATA data;
data.data_in = input.data();
data.input_frames = input.size() / channels_;
data.data_out = outputBuffer_.data();
data.output_frames = outputBuffer_.size() / channels_;
data.src_ratio = static_cast<double>(dstRate_) / srcRate_;
data.end_of_input = 0;
int ret = src_process(srcState_, &data);
if (ret != 0) {
throw std::runtime_error(src_strerror(ret));
}
return std::vector<float>(outputBuffer_.begin(),
outputBuffer_.begin() + data.output_frames_gen * channels_);
}
private:
int srcRate_, dstRate_, channels_;
SRC_STATE* srcState_ = nullptr;
int error_ = 0;
std::vector<float> outputBuffer_{1024 * 1024}; // 预分配1MB
};
20.6 整体架构图
下面这张图展示了音频采集、播放、格式转换的整体流程:
从图中可以看到,整个架构分为五层:硬件层、PortAudio跨平台层、回调封装层、业务逻辑层(采集/播放)、格式转换层。每一层只负责自己的事情,层与层之间通过接口解耦。这样设计的好处是,如果将来要换底层音频库(比如从PortAudio换成RtAudio),只需要改回调封装层,上层代码完全不用动。
20.7 避坑指南与最佳实践
最后,分享几个我在音频开发中踩过的坑:
- 缓冲区大小不是越小越好:64帧的延迟确实低,但CPU稍微一忙就爆音。我建议从256帧开始调,根据实际平台和负载逐步降低。
- 回调里不要分配内存:音频回调是实时线程,
malloc、new、std::vector::push_back这些操作都可能触发锁或者缺页中断,导致延迟抖动。所有缓冲区都提前分配好。 - 采样率转换一定要用高质量算法:线性插值虽然快,但高频会有混叠失真。libsamplerate的
SRC_SINC_BEST_QUALITY模式虽然慢一点,但音质有保障。 - 注意字节序:WAV文件是小端字节序,而某些嵌入式平台可能是大端。跨平台时一定要做字节序转换。
- 测试不同设备:同一个程序,在笔记本内置声卡上跑得好好的,插上USB麦克风可能就出问题。多设备测试是必须的。
音频处理这块,说白了就是“实时”和“质量”的平衡。你追求低延迟,就得接受偶尔的爆音;你追求高音质,就得忍受稍高的延迟。没有银弹,只有根据场景做取舍。希望今天的分享能帮你少走一些弯路。