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来保护读写指针。

注意:PortAudio的回调是在单独的音频线程中执行的。如果你在回调里访问了主线程的数据,一定要用原子操作或者无锁队列。我曾经在回调里直接操作了一个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 (跨平台音频API) 音频回调 (C++虚函数封装) AudioRecorder (采集数据到环形缓冲区) AudioPlayer (从数据源播放) 格式转换器 (位深度/采样率/声道转换) 硬件 跨平台层 回调封装 业务逻辑 格式转换

从图中可以看到,整个架构分为五层:硬件层、PortAudio跨平台层、回调封装层、业务逻辑层(采集/播放)、格式转换层。每一层只负责自己的事情,层与层之间通过接口解耦。这样设计的好处是,如果将来要换底层音频库(比如从PortAudio换成RtAudio),只需要改回调封装层,上层代码完全不用动。

20.7 避坑指南与最佳实践

最后,分享几个我在音频开发中踩过的坑:

  • 缓冲区大小不是越小越好:64帧的延迟确实低,但CPU稍微一忙就爆音。我建议从256帧开始调,根据实际平台和负载逐步降低。
  • 回调里不要分配内存:音频回调是实时线程,mallocnewstd::vector::push_back这些操作都可能触发锁或者缺页中断,导致延迟抖动。所有缓冲区都提前分配好。
  • 采样率转换一定要用高质量算法:线性插值虽然快,但高频会有混叠失真。libsamplerate的SRC_SINC_BEST_QUALITY模式虽然慢一点,但音质有保障。
  • 注意字节序:WAV文件是小端字节序,而某些嵌入式平台可能是大端。跨平台时一定要做字节序转换。
  • 测试不同设备:同一个程序,在笔记本内置声卡上跑得好好的,插上USB麦克风可能就出问题。多设备测试是必须的。

音频处理这块,说白了就是“实时”和“质量”的平衡。你追求低延迟,就得接受偶尔的爆音;你追求高音质,就得忍受稍高的延迟。没有银弹,只有根据场景做取舍。希望今天的分享能帮你少走一些弯路。

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