Qt6 QAudioSink网络PCM音频播放:从格式配置到缓冲设计 最近在做一款跨平台的实时语音传输模拟项目技术选型敲定用 Qt6.8 QAudioSink 播放网络PCM语音。原始PCM不封装、不压缩直接从UDP通道送过来接收端要保证声音不断、不乱、不崩这个活儿看着简单做起来全是细节。这篇文章把从音频格式配置、缓冲设计到收流播放的完整链路拆开讲适合正在用Qt做网络语音、对讲机、门禁、车机互联这类项目的人参考。如果你只是想在本地播放一个PCM文件也可以只重点关注 QAudioSink 初始化和缓冲计算那几节。1. 项目概述与整体链路设计1.1 网络PCM播放要解决什么网络PCM语音先有一层物理事实PCM 是裸采样数据本身没有帧边界、没有时间戳、没有压缩头。采集端只是按固定采样率把麦克风数据切成小块塞进 UDP 报文接收端拿到一堆字节后不能把包当文件直接写完而必须以声卡消费的速度持续喂给音频设备。音频播放是固定速率的“水管”UDP 网络却是突发式到达的“水龙头”两者天然不同步。所以越界问题核心就两件事第一播放端必须知道采样格式否则字节进来等于噪声第二播放端要有足够大的缓冲来抵消网络抖动让“水龙头”的忽大忽小变成“水管”的均匀流动。很多人第一次做网络播放就栽在这里缓冲区开太小网络一抖声卡就空转出现噼啪声开太大延迟又高得没法用。这个平衡是这篇文章反复出现的主题。1.2 模块职责划分把这个项目拆开后主要模块其实只有四个QUdpSocket负责收网络包把有效载荷取出来。音频环形缓冲负责抗抖动解耦网络线程和音频线程。QAudioSink负责把 PCM 字节送到声卡。QAudioFormat负责约定和校验采样参数。四个模块里QUdpSocket 和 QAudioSink 都是现成的真正需要自己写的核心是环形缓冲和启动时机控制。QAudioSink 只是“消耗者”你要保证它每次来拿数据时缓冲里都有货。1.3 为什么用 QAudioSink 而不是 QAudioOutputQt6 里很多人容易搞混 QAudioOutput 和 QAudioSink。QAudioOutput 是高层媒体播放对象通常和 QMediaPlayer 配合接受的是经过封装的媒体流带播放、暂停、跳转这些控制语义。QAudioSink 是低层音频输出口直接吃裸 PCM 数据没有包装没有格式解析你给它音频格式它把字节变成声音。这个项目要的是“网络PCM语音”不是播放音乐文件所以 QAudioSink 是正确入口。另外注意Qt6 里已经不存在 Qt5 时代的QAudioOutput低层类如果搜到老代码尤其中文博客里写的QAudioOutput接裸流基本是 Qt5 的旧 API直接照抄会编译报错。Qt6.8 里请认准QAudioSink。2. 环境准备与PCM格式约定2.1 工程配置首先确认你的 Qt 版本我实际用的是 Qt 6.8。Qt 6 的音频模块需要显式链接 MultimediaCMake 里这样配cmake_minimum_required(VERSION 3.21) project(pcm_player LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Multimedia) add_executable(pcm_player main.cpp) target_link_libraries(pcm_player PRIVATE Qt6::Multimedia)代码里要包含的头文件大致是这几个#include QAudioSink #include QAudioFormat #include QAudioDevice #include QMediaDevices #include QUdpSocket #include QIODevice纯音频播放不需要加 QWidget用QCoreApplication就能跑所以这是一个可以完全去掉界面的 headless 程序。这点对嵌入式设备和后台服务很友好。2.2 PCM三要素与字节速率QAudioSink 拿到字节前QAudioFormat 必须告诉它三个基本信息采样率每秒采多少次常见 8000、16000、44100、48000。采样格式每个样本怎么编码网络语音最常用Int16。声道数单声道还是双声道。字节速率可以简单算出来字节速率 采样率 × 声道数 × 每个样本字节数例如 16kHz、单声道、16bit PCM16000 × 1 × 2 32000 字节/秒。这就是声卡每秒消耗的速度。20 毫秒的声音数据量是32000 × 0.02 640字节。这个计算以后每调一次缓冲都要用。如果你做的是对讲机推荐固定成 16kHz 单声道 Int16带宽占用最低语音清晰度足够。做音乐传输再考虑 44100 或 48000 双声道。2.3 格式协商与简单包结构UDP 报文里不会自动带上采样率、位深、声道数。这些信息必须通过“带外协商”或“包头携带”的方式让接收端知道。否则接收端拿到的只是字节根本不知道是该按 16kHz 还是 48kHz 解释。最省事的做法是双端写死格式代码注释里写清楚。但真实项目里我更推荐在 UDP 包前面加一个固定包头哪怕只放版本号、采样率、声道数、载荷长度、序列号这几个字段。序列号很重要后面做丢包判断和乱序处理都要用。如果做灵活版本包结构可以长这样#pragma pack(push, 1) struct PcmPacketHeader { quint32 magic; // 0x50434D31 PCM1 quint16 seq; // 序列号 quint16 sampleRate; // 16000 quint8 channelCount; // 1 quint8 sampleFormat; // 2 表示 Int16 quint16 payloadSize; // PCM 字节数 }; #pragma pack(pop)解析时用memcpy从原始字节里读字段避免直接强转 struct 引发对齐问题。如果发送端和接收端可能在不同架构数字字段还要统一转成网络字节序例如qToBigEndian。目前的网络语音项目大部分还是 x86 对 x86可以先不管大小端但心里要有这根弦。3. QAudioSink初始化与缓冲区设计3.1 选择输出设备初始化音频前先拿输出设备。Qt 6 用QMediaDevices::defaultAudioOutput()获取系统默认输出返回QAudioDevice。如果设备为空说明这台机器没有可用的音频输出直接报错退出。QAudioDevice device QMediaDevices::defaultAudioOutput(); if (device.isNull()) { qWarning() no audio output device; return; }如果你需要让用户选择不同扬声器可以用QMediaDevices::audioOutputs()枚举所有输出设备然后把选择结果存起来后续创建 QAudioSink 时传入对应 QAudioDevice。3.2 QAudioFormat配置与格式校验QAudioFormat 的配置要和你协商好的 PCM 参数完全一致。这里我按 16kHz、单声道、Int16 来写QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(1); format.setSampleFormat(QAudioFormat::Int16);调用device.isFormatSupported(format)可以提前确认声卡是否支持这种格式。大多数桌面声卡都支持 16kHz 单声道 16bit但如果碰上老旧设备或者特殊嵌入式采集卡可能会返回 false。这时候有两个选择让算法重采样到设备支持的格式或者遍历设备的supportedSampleFormats()找一个最接近的格式再处理。Qt6 的 QAudioFormat 已经不再像 Qt5 那样把位深和字节序拆成sampleSize和byteOrder现在统一用sampleFormat表示。如果你在网络里传的是小端 Int16而播放端是大端平台QAudioFormat 本身不会自动帮你换字节序需要自己在收到数据后做字节交换。桌面平台一般默认小端这个问题我后面在排查章节再细说。3.3 创建QAudioSink与两种启动模式格式校验通过后创建 QAudioSinkQAudioSink *sink new QAudioSink(device, format, this); sink-setBufferSize(6400); // 200ms按 32000B/s 算QAudioSink 有 push 和 pull 两种工作模式这是新手最容易绕晕的地方。Push 模式调用无参start()QAudioSink 返回一个QIODevice*你主动向它write()音频数据。相当于你的代码控制什么时间塞数据。Pull 模式调用start(QIODevice*)传入你自己的设备对象。QAudioSink 内部的音频线程会主动调用你的QIODevice::read()来取数据。相当于声卡来要货。网络播放场景我更推荐 pull 模式。原因是网络线程和音频线程天然解耦QAudioSink 自己按声卡时钟消费数据你的网络线程只负责往环形缓冲里塞包两边不需要互相等待也不会出现某线程因为声卡停顿而一直阻塞。Push 模式写起来简单但你必须自己 care 写入节奏和bytesFree()稍不注意就把内部缓冲塞满导致延迟越堆越高。3.4 缓冲区大小与延迟计算QAudioSink 的缓冲区大小单位是字节不是毫秒。设置前先按前面算好的字节速率换算延迟毫秒 缓冲区字节 / 字节速率 × 1000比如 6400 字节6400 / 32000 × 1000 200ms。这是我建议的网络语音起步值。太小容易 underrun太大延迟明显。如果你做的是局域网项目乱序和抖动不大150ms 就够如果走公网300ms 也正常。需要注意的是setBufferSize()只是“请求”一个大小底层后可能返回一个不同值。创建完后用sink-bufferSize()读一下实际大小后面所有算法都按实际值算不要假设它一定等于你设置的数字。sink-setBufferSize(6400); qint64 actualBuffer sink-bufferSize();还有一个容易忽略的点QAudioSink 的内部缓冲并不是总延迟的全部。底层声卡还有一圈硬件缓冲驱动、系统音频服务也会贡献几十毫秒延迟。所以就算应用层缓冲只设了 100ms实际听感延迟可能到 150ms 以上。项目验收时你如果觉得延迟超标要从整体链路看不能只盯着 QAudioSink。3.5 初始化骨架汇总把上面连起来初始化部分的核心长这样bool initAudio(QAudioSink *sink, QAudioFormat format) { QAudioDevice device QMediaDevices::defaultAudioOutput(); if (device.isNull()) return false; format.setSampleRate(16000); format.setChannelCount(1); format.setSampleFormat(QAudioFormat::Int16); if (!device.isFormatSupported(format)) { qWarning() audio format not supported; return false; } sink new QAudioSink(device, format); sink-setBufferSize(6400); return true; }到这一步QAudioSink 已经准备好但还没有 start。为什么先不 start因为纯网络场景里你要等环形缓冲积累到一定量再开闸放水否则前几包数据一进来就会立即被声卡吃光后面没跟上就断流。4. 网络PCM收流与播放实现4.1 UDP收流线程与事件循环UDP 接收最简单的方式是挂在 Qt 事件循环上用QUdpSocket的readyRead信号。m_socket.bind(QHostAddress::AnyIPv4, 8899); connect(m_socket, QUdpSocket::readyRead, this, PlayerCore::onReadyRead);接收函数里循环取完所有 datagram避免一个事件只处理一个包导致积压void PlayerCore::onReadyRead() { while (m_socket.hasPendingDatagrams()) { QByteArray datagram; datagram.resize(static_castint(m_socket.pendingDatagramSize())); m_socket.readDatagram(datagram.data(), datagram.size()); // 如果带头部在这里解析头部并判断格式 // 如果不带头部直接当成 PCM 载荷 m_buffer-append(datagram); } tryStartPlayback(); }如果包有自定义头部要在这里完成校验和剥离。包头不对的包要么丢弃要么按旧格式走绝对不能混进音频缓冲否则声卡会把包头当 PCM 数据播放爆出一串刺耳噪声。如果发现缓冲满了策略有两种丢新包或者丢最老的包。语音实时性优先我个人倾向丢最老的包因为老数据已经过期继续播只会增加延迟。4.2 抗抖动缓冲的实现抗抖动缓冲本质上就是一个线程安全、容量固定、先进先出的字节队列。我用一个继承QIODevice的类来实现这样可以直接传给 QAudioSink 做 pull 模式。class AudioJitterBuffer : public QIODevice { public: explicit AudioJitterBuffer(qint64 capacityBytes) : m_capacity(capacityBytes) { open(QIODevice::ReadWrite); } void append(const char *data, qint64 len) { QMutexLocker locker(m_mutex); if (len 0) return; if (m_buffer.size() len m_capacity) { qint64 overflow m_buffer.size() len - m_capacity; m_buffer.remove(0, static_castint(overflow)); } m_buffer.append(data, static_castint(len)); } void append(const QByteArray data) { append(data.constData(), data.size()); } qint64 bytesAvailable() const override { QMutexLocker locker(m_mutex); return m_buffer.size() QIODevice::bytesAvailable(); } protected: qint64 readData(char *data, qint64 maxlen) override { QMutexLocker locker(m_mutex); qint64 n qMinqint64(maxlen, m_buffer.size()); if (n 0) return 0; memcpy(data, m_buffer.constData(), static_castsize_t(n)); m_buffer.remove(0, static_castint(n)); return n; } qint64 writeData(const char *data, qint64 len) override { append(data, len); return len; } private: qint64 m_capacity; QByteArray m_buffer; mutable QMutex m_mutex; };这个实现里QByteArray::remove(0, n)是线性复杂度数据量不大的时候完全可以接受。你要是追求极致性能可以把底层改成读写指针加固定数组的环形结构原理一样只是省了 memmove。照这样设计网络线程只调用append()音频线程通过 QAudioSink 间接调用readData()。两个线程用同一个互斥锁保护缓冲区简单可靠。注意不要在互斥锁里做耗时操作比如大数据包 memcpy 可以接受但不要在这里做重采样或编解码。4.3 播放启动时机与预取策略网络刚开始收包时缓冲里只有零星几包这时候直接启动 QAudioSink 很容易造成短暂饥饿。正确做法是设置一个预取阈值缓冲长度达到阈值后再启动播放。const qint64 kPrerollBytes 3200; // 100ms void PlayerCore::tryStartPlayback() { if (m_playing) return; if (m_buffer-bytesAvailable() kPrerollBytes) { m_sink-start(m_buffer); m_playing true; } }预取 100ms 的意思是先攒够 100ms 的声音再开播。这样启动后即便网络来一阵空窗之前的 100ms 存储也足够给网络一个反应时间。这个值不是越大越好预取越大首句延迟越大用户会感觉按下通话键后不能马上听到回复。我实测下来局域网 100ms 预取公网 200ms 到 300ms 预取是比较稳的区间。如果在播放过程中长时间没有新数据到达说明对端已经停止说话或网络断了。这时候可以调用m_sink-reset()清掉内部缓冲把m_playing置回 false清空环形缓冲等下一段语音再来时重新做预取。否则网络停顿后突然恢复你在旧缓冲的尾巴后面继续接新数据听感会错位。4.4 状态监听与异常处理QAudioSink 提供两个重要信号stateChanged和errorOccurred。项目上线后至少要把这两个信号打点打出来否则出了问题只能靠猜。connect(m_sink, QAudioSink::stateChanged, this, [](QAudio::State state) { qInfo() sink state: state; }); connect(m_sink, QAudioSink::errorOccurred, this, [](QAudio::Error error) { qWarning() sink error: error; });常见的是QAudio::UnderrunError也就是底层想消费数据但应用没喂上。这个错误不致命出现后只要继续喂数据声卡状态能自己恢复。但如果频繁出现说明缓冲开小了或网络抖动太夸张要回头调缓冲大小。QAudio::IOError和QAudio::FatalError比较严重通常是音频设备被拔掉、驱动崩溃或者权限问题。遇到这种情况需要销毁旧 QAudioSink 并重新初始化单纯继续写入不一定有效。我之前在 USB 声卡被拔掉时遇到过不重建对象后面再怎么写都返回不了正常状态。4.5 Push 模式的替代写法如果你的场景不想用自定义 QIODevice也可以走 push 模式QIODevice *sinkDevice sink-start(); sinkDevice-write(pcmData, pcmDataSize);这种写法适合你已经有一个稳定的数据源、发送节奏和声卡消费速率天然匹配的情况。但你要注意两点第一write()不一定一次写完所有数据返回值可能是部分字节数。没写完的部分要留在你的发送队列里下次继续写不能直接丢掉。第二接收缓冲接近满时bytesFree()会变小甚至长时间返回 0。这时需要主动暂停写入或丢弃过期包否则数据会越堆越慢延迟像滚雪球一样膨胀。所以我始终推荐 pull 模式。QAudioSink 自己知道声卡要多少它来读你就给网络线程用 append 填充即可两侧天然背压。5. 常见问题排查与性能优化5.1 播放完全没有声音排查顺序要固定。先确认 QAudioFormat 协商没问题再确认数据有没有真正到达播放层最后怀疑设备音量。先做一个最小验证不经过网络直接给 QAudioSink 写一段正弦波或播放一个 WAV 文件。如果正弦波有声说明 QAudioSink 链路正常问题在网络侧如果正弦波也没声检查默认输出设备、系统音量、设备是否被其他程序独占。这一步能砍掉一半问题。网络侧没声音最常见的原因有三个一是默认音频设备拿错了比如明明有多个声卡系统默认选择了 HDMI 而不是扬声器二是包格式不匹配格式协商写的是 16kHz实际发送端给的是 48kHz播放端照 16kHz 解释声音要么没有要么变成怪声三是重启播放后没有重新预取启动瞬间缓冲是空的。5.2 声音断断续续、咔哒声明显卡顿的根源就一个声卡需要数据的时候缓冲里没货。分为突发和持续两种。突发卡顿通常来自网络抖动LAN 下几十毫秒的抖动也很常见。对策是增加预取深度比如从 100ms 加到 200ms。同时检查是不是每个 UDP 包都在同一时刻突发到达比如采集端攒了 10 个包一次性发出来接收端一下塞进缓冲后面却断流。这种问题要回到采集端把发送节奏打匀每 20ms 发一个 640 字节的包。持续卡顿最可能是字节速率不匹配。接收端以为声卡每秒消耗 32000 字节实际发送端发的却是 48000 字节QAudioSink 内部缓冲会反复“满-空-满-空”表现就是声音一顿一顿。这类问题要在协议层彻底固定格式别让两端各猜各的。5.3 声音变调、发闷或音调升高PCM 参数不匹配的第一症状就是变调。采样率发的是 48000播放端按 16000 播声音会变慢变闷反之16kHz 的数据按 48kHz 播声音会变快变尖像磁带快进。排查时按这个顺序对采样率是否一致。声道数是否一致采集端双声道按交错排列发送播放端按单声道解析速率差两倍声音会明显变形。位深和字节序是否一致Int16 数据被当作 Int8 解析会产生爆音大端小端反了会出现大量毛刺噪声。包头剥离是否正确包头没去掉会每包开头混入几个字节的垃圾听着像规律的爆点。里面最容易藏问题的是声道数。很多采集设备默认输出双声道但格式配置写的是单声道播放端会把左右声道交错数据当成同一个流处理听起来像是背景有人说话但内容完全糊掉。5.4 延迟越来越高延迟持续增加的原因是写入速度长期大于消费速度。尤其 push 模式下如果代码只调 write 不管bytesFree()数据只会越积越多。pull 模式下出现延迟膨胀多半是环形缓冲上限设得太大加上网络长期不稳定。UDP 包慢速到达时缓冲里旧数据一直没播完新数据又挤进来整体播放时间被越推越后。解决方法是给环形缓冲设一个硬上限超过上限就丢最老的包宁可丢回来的数据也不要让整个会话的延迟越滚越大。我在实际调试中会把延迟做成可观测指标每秒钟打印一次m_sink-processedUSecs() / 1000和环形缓冲占用字节数。看到这两个值稳定增长说明缓冲在膨胀看到 processedUSecs 长时间不变说明声卡可能 suspend 了。5.5 不同平台的坑Linux 上默认输出设备经常指向 PulseAudio/PipeWire 的虚拟设备设备支持格式和直接访问 ALSA 设备不同。如果isFormatSupported返回 false不要立刻换格式先看看系统音频服务是否正常运行。Windows 上 WASAPI 共享模式的周期一般是 10ms应用层缓冲太小时容易频繁触发 underrun。建议至少设 40ms 到 80ms 的应用层缓冲给系统调度留余量。macOS 上 CoreAudio 对采样率转换比较积极某些设备甚至会自动 resample你设置 16kHz 它可能也接受。但这会掩盖格式问题所以不能因为“能出声”就忽略格式校验。嵌入式 Linux 上如果没有音频服务直接用 ALSA 设备节点要确认设备独占问题。Kodi、浏览器等程序可能已经占用了声卡QAudioSink 会打不开设备返回QAudio::OpenError。6. 一个可直接跑的接收端Demo把所有内容合并成一个最小可运行文件。这里假设发送端直接把裸 PCM 放进 UDP 报文不带任何自定义头部双端约定 16kHz、单声道、Int16。完整代码如下#include QCoreApplication #include QUdpSocket #include QAudioSink #include QAudioFormat #include QAudioDevice #include QMediaDevices #include QIODevice #include QHostAddress #include QMutex #include QDebug static constexpr int kSampleRate 16000; static constexpr int kChannelCount 1; static constexpr int kSampleBytes 2; static constexpr int kBytesPerSec kSampleRate * kChannelCount * kSampleBytes; inline qint64 msToBytes(int ms) { return (kBytesPerSec * ms) / 1000; } class AudioJitterBuffer : public QIODevice { public: explicit AudioJitterBuffer(qint64 capacityBytes) : m_capacity(capacityBytes) { open(QIODevice::ReadWrite); } void append(const QByteArray data) { QMutexLocker locker(m_mutex); if (data.isEmpty()) return; if (m_buffer.size() data.size() m_capacity) { qint64 overflow m_buffer.size() data.size() - m_capacity; m_buffer.remove(0, static_castint(overflow)); } m_buffer.append(data); } qint64 bytesAvailable() const override { QMutexLocker locker(m_mutex); return m_buffer.size() QIODevice::bytesAvailable(); } protected: qint64 readData(char *data, qint64 maxlen) override { QMutexLocker locker(m_mutex); qint64 n qMinqint64(maxlen, m_buffer.size()); if (n 0) return 0; memcpy(data, m_buffer.constData(), static_castsize_t(n)); m_buffer.remove(0, static_castint(n)); return n; } qint64 writeData(const char *data, qint64 len) override { append(QByteArray(data, static_castint(len))); return len; } private: qint64 m_capacity; QByteArray m_buffer; mutable QMutex m_mutex; }; class PlayerCore : public QObject { Q_OBJECT public: PlayerCore(quint16 port, QObject *parent nullptr) : QObject(parent) { m_format.setSampleRate(kSampleRate); m_format.setChannelCount(kChannelCount); m_format.setSampleFormat(QAudioFormat::Int16); QAudioDevice device QMediaDevices::defaultAudioOutput(); if (device.isNull() || !device.isFormatSupported(m_format)) { qFatal(audio device or format not support); } m_jitterBuffer new AudioJitterBuffer(msToBytes(500), this); m_sink new QAudioSink(device, m_format, this); m_sink-setBufferSize(msToBytes(200)); connect(m_socket, QUdpSocket::readyRead, this, PlayerCore::onReadyRead); m_socket.bind(QHostAddress::AnyIPv4, port); qInfo() listening on port port; } private slots: void onReadyRead() { while (m_socket.hasPendingDatagrams()) { QByteArray datagram; datagram.resize(static_castint(m_socket.pendingDatagramSize())); m_socket.readDatagram(datagram.data(), datagram.size()); m_jitterBuffer-append(datagram); } if (!m_playing m_jitterBuffer-bytesAvailable() msToBytes(100)) { m_sink-start(m_jitterBuffer); m_playing true; } } private: QUdpSocket m_socket; QAudioSink *m_sink nullptr; AudioJitterBuffer *m_jitterBuffer nullptr; QAudioFormat m_format; bool m_playing false; }; #include main.moc int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); quint16 port 8899; if (argc 1) port QString::fromLocal8Bit(argv[1]).toUShort(); PlayerCore core(port); return app.exec(); }编译方式和普通 Qt 工程一样跑起来后发送端只要往目标端口持续发 16kHz 单声道 Int16 的裸 PCM 数据就能听到声音。我自己在这个 Demo 的基础上做的下一件事就是加自定义包头和序列号因为一旦到了公网环境没有序列号根本没法做丢包统计。QAudioSink 本身并不难调真正的功夫都在包格式、预取阈值和缓冲上限上。把这三件事的数据做成日志效果好不好一眼就能看出来。