前阵子接了个桌面工具的小需求,界面用的是 Qt,业务里需要把电脑正在播放的声音连同麦克风一起录下来,类似录屏工具里的“系统声音 + 麦克风混录”。一开始我以为这是“开局一条指令”的活,研究完才发现 Windows 音频采集的水比想象中深:Qt 自带的 QAudioInput 只能采麦克风,FFmpeg 的 dshow 方案依赖声卡“立体声混音”是否启用,真正的稳定解是走 WASAPI 的 Loopback 模式拿系统播放流,再用 FFmpeg 做封装输出。这篇文章就把我怎么把 Qt、WASAPI、FFmpeg 这条链路搭起来的过程写透,包括方案取舍、环境配置、代码骨架和实测里踩过的坑。如果你正准备给 Qt 应用加录音功能,或者已经在“采集到静音”和“偶尔爆音”之间反复横跳,这篇应该能让你少走不少弯路。
1. 一开始为什么走到 QAudioInput 就卡住了:三种采集方案的对比
1.1 QAudioInput 只能解决麦克风,解决不了“系统正在播放的声音”
很多做 Qt 应用的人第一反应是 QAudioInput,因为 Qt Multimedia 模块的 API 确实简单,几行代码就能拿到音频设备输入。问题是它拿到的永远是“输入设备”,也就是麦克风这一路,而系统正在输出的声音——比如后台 BGM、网页视频、来电铃声——对 QAudioInput 来说是看不见的。
我当时的需求其实有两条轨:麦克风是必须的,系统播放声也必须录进去。用 QAudioInput 做麦克风没问题,但第二条轨立刻卡住了。后来查资料发现 Qt 在 Windows 上有一条相对冷门的路径可以拿到系统播放流,但兼容性一般,而且采样格式控制起来很别扭。与其去折腾那些半残的封装,不如直接回到 Windows 系统接口层。
1.2 dshow 的“立体声混音”看起来对,但不太敢在正式产品里用
网上很多帖子会推荐 FFmpeg 命令行直接采集系统声音,命令长这样:
ffmpeg -f dshow -i audio="立体声混音" out.wav这套方案在“自己电脑上玩一玩”是完全没问题的,但你打开声卡设置面板看一眼就会发现,所谓的“立体声混音”通常是禁用状态,而且不同声卡驱动里这一项的名字还不一样,有的是英文 Stereo Mix,有的是中文,有的干脆没有。做正式产品不能指望用户手动去启用一个驱动级的混音设备,万一用户换个声卡或者驱动更新后名字变了,这个功能直接就废了。
另外 dshow 这条路的采样格式稳定性也不好,有些设备在采集过程中会突然跳格式,程序如果硬编码了 44100Hz 就容易录出变速音频。相比之下 WASAPI 的 Loopback 模式是操作系统级的接口,只要当前系统能播放声音,就能拿到正在播放的那份 PCM 数据,不依赖任何驱动隐藏设备,稳定性要高出很多。
1.3 最终取舍:WASAPI 采集 + Qt 调度 + FFmpeg 封装
我把三种方案摆在一起做了个对比,这也是一开始走弯路之后总结出来的结论:
| 方案 | 能采系统播放声 | 稳定性 | 格式可控性 | 集成成本 |
|---|---|---|---|---|
| Qt QAudioInput | 不能 | 高 | 一般 | 最低 |
| FFmpeg dshow | 能但不稳定 | 中低 | 低 | 中 |
| WASAPI Loopback | 能 | 高 | 高 | 中高 |
Qt 负责界面和线程调度,WASAPI 负责从声卡拿 PCM 原始流,FFmpeg 负责把 PCM 封装成 WAV 文件。每条链路只用各自最擅长的部分,这就是最终方案的骨架。
提示:如果只录麦克风,直接用 Qt 自带模块就够了,没必要上 WASAPI + FFmpeg 的组合。只有当系统播放声也是必需采集对象时,才值得引入这一整套链路。
2. 环境配置:FFmpeg 库与 Qt 构建器的匹配,折腾半天才消停
2.1 选共享库还是静态库,以及版本问题
FFmpeg 的 Windows 构建通常两种形式:一种是 shared,包含 dev 头文件和 bin 动态库;一种是 static,直接给你一个巨型静态库。我优先推荐 shared,因为静态拉 FFmpeg 进 Qt 工程时,编译器版本、C Runtime 不匹配会带来一堆符号问题,排查起来非常痛苦。我们实际项目里用的 FFmpeg 版本在 5.1 到 6.0 之间,其实功能差异不大,关键是库内部符号和外部编译器的 ABI 要对得上。
我个人锁定的组合是 Qt 5.15 或 Qt 6.x + MSVC 构建器。如果非要用 MinGW,也能跑,但找对应当前编译器版本的 FFmpeg 构建会费一点力,版本选错了最典型的症状就是链接错误里全是cannot find -lavformat或者一堆无法解析的外部符号。宁可多花十分钟下载正确的编译结果,也不要硬着头皮去适配。
2.2 Qt 工程里链接 FFmpeg 的具体写法
qmake 工程里直接在 .pro 文件加几行就行:
INCLUDEPATH += $$PWD/ffmpeg/include LIBS += -L$$PWD/ffmpeg/lib -lavformat -lavcodec -lavutilCMake 工程则这样:
include_directories(${CMAKE_SOURCE_DIR}/ffmpeg/include) target_link_libraries(MyApp PRIVATE ${CMAKE_SOURCE_DIR}/ffmpeg/lib/avformat.lib ${CMAKE_SOURCE_DIR}/ffmpeg/lib/avcodec.lib ${CMAKE_SOURCE_DIR}/ffmpeg/lib/avutil.lib )头文件一定要用extern "C"包起来,这是 C++ 引入 C 库的常规操作,漏掉这一层就会出现大量“未定义引用”错误。FFmpeg 4.x 以后已经不需要调用av_register_all()了,如果你看到老教程里有这行,把它删掉,不然在 5.x 或 6.x 版本上会编译不过。
注意:链接顺序在 Linux 上很讲究,库必须从依赖方到被依赖方排列,否则会报一堆未定义引用。Windows 上的 CMake 也保留了这个脾气,按 avformat → avcodec → avutil 的顺序来是最稳的。
2.3 DLL 部署:能跑起来不等于发布没问题
开发机上程序能跑,发布之后客户机器上闪退,这类问题在 Qt + FFmpeg 组合里很常见,九成原因是 DLL 没带全。Qt 的项目用windeployqt就能自动把 Qt 相关的运行时拷到 exe 目录:
windeployqt --release your_app.exeFFmpeg 的动态库则要手动拷贝,通常把bin目录下的这几个文件复制到 exe 同目录就行:avformat.dll、avcodec.dll、avutil.dll,另外可能还需要swresample.dll、swscale.dll。稳妥的做法是把整个 bin 目录的都拷过去,反正加起来也没多大。
这里有个小坑:如果用过其他依赖 FFmpeg 的软件,系统 PATH 里可能有旧版本 DLL,程序加载出错时会优先加载到错误的版本。绝对不要在 exe 目录和系统目录里出现同一份 DLL 的两个版本,真遇到了就把 exe 目录里的删除干净,再重新拷贝一份。
3. 核心架构:采集线程与写文件线程之间,最好隔一个环形队列
3.1 为什么不能把 WASAPI 里的数据直接写文件
WASAPI 采集住的数据不能直接交给 FFmpeg 写文件,原因有两个:一是 WASAPI 的音频回调或者循环读取必须尽快把缓冲区的数据取走,否则会丢包;二是文件写入是磁盘 I/O,可能会有几十毫秒的阻塞,如果采集线程卡在写文件上,音频数据就断了,表现出来就是录音里一段一段的爆音。
所以正确的做法是:采集线程只做一件事——从 WASAPI 手里拿到 PCM 数据,塞进一个容量固定的缓冲区;另一个封装线程从缓冲区里取数据,交给 FFmpeg 写文件。两个线程之间用“生产者—消费者”模型解耦。界面线程如果需要管录音状态,再通过信号槽跟这两个线程交互。
3.2 环形队列的容量怎么定
缓冲区大小不是拍脑袋定的,先算一下码率。假设 PCM 参数是 48000Hz、双声道、16bit,那一秒的数据量是:
48000 × 2 × 2 = 192000 字节/秒 ≈ 187.5 KiB/s换算下来一分钟大约 11.25MB,一小时大约 660MB。这样你还能顺带估算录制文件体积,方便跟产品沟通“录制一小时大概要占多少硬盘”。
缓冲区容量我建议按照录音流畅性来定,太小容易在系统瞬间繁忙时爆音,太大又会让停止录制时尾部残留大量待写数据。200ms 级别的缓冲是最常用的,也就是大约 38KB。我自己的习惯是给 500ms 缓冲兜底,这样即使磁盘写入偶尔慢一点也不会影响采集线程。
3.3 生命周期控制:开始、停止、设备断开重连
线程的生命周期要比 UI 按钮的开关更谨慎。开始录制时,先启动写文件线程,让 FFmpeg 做好写 WAV 的准备,再启动 WASAPI 采集。停止录制时反过来,先停 WASAPI,把缓冲区里剩余的数据全部写入文件,再让 FFmpeg 收尾——写文件尾部信息,关闭文件句柄——最后退出写文件线程。
如果设备被拔掉、或者系统默认播放设备切换了,WASAPI 循环里可能会一直拿不到数据包,表现为GetNextPacketSize永远返回 0。严谨的做法是设置一个超时计数,连续一段时间没有数据就重新初始化采集端,并打印错误日志,方便后续排查。这块别偷懒,实际使用时“录出来一个 0 字节文件”往往不是采集逻辑的问题,而是设备早已断开但线程没有感知。
4. 关键代码:WASAPI 拿 PCM 与 FFmpeg 写 WAV 的最小实现
4.1 WASAPI 初始化与启动采集
WASAPI 的初始化过程有点啰嗦,但流程固定。先初始化 COM,然后枚举默认渲染设备,激活 IAudioClient,获取混音格式,再以 Loopback 共享模式初始化。直接看代码骨架:
// 初始化 COM CoInitializeEx(nullptr, COINIT_MULTITHREADED); IMMDeviceEnumerator* enumerator = nullptr; IMMDevice* device = nullptr; IAudioClient* audioClient = nullptr; IAudioCaptureClient* captureClient = nullptr; CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator)); enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, (void**)&audioClient); WAVEFORMATEX* mixFormat = nullptr; audioClient->GetMixFormat(&mixFormat); // 拿到当前系统混音格式 audioClient->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, 200 * 10000, // 缓冲区 200ms,单位是 100ns 0, mixFormat, nullptr); audioClient->GetService(IID_PPV_ARGS(&captureClient)); audioClient->Start();特别留意GetDefaultAudioEndpoint(eRender, eConsole)里的eRender,Loopback 模式必须挂在渲染端点(输出设备)上,如果这里用了 eCapture,那就拿不到系统播放流了。初始化结束后就可以在一个独立线程里循环读取数据:
while (running) { UINT32 packetSize = 0; captureClient->GetNextPacketSize(&packetSize); if (packetSize == 0) { Sleep(1); continue; } BYTE* data = nullptr; UINT32 frames = 0; DWORD flags = 0; captureClient->GetBuffer(&data, &frames, &flags, nullptr, nullptr); // data 就是完整的 PCM 数据,frames 是本次包内的采样帧数 // 把这块数据交给环形队列 captureClient->ReleaseBuffer(frames); }GetBuffer拿到的数据是分帧的,但是是按音频帧为单位划分的,“帧”在这里指一次采样周期内所有声道的数据。比如双声道 16bit,一帧就是 4 字节。如果每次包是 1024 帧,那数据量就是 1024 × 4 = 4096 字节,这个换算关系后面写 FFmpeg 封装时要保持一致。
4.2 把 PCM 喂给 FFmpeg
WASAPI 侧拿到 PCM 原始流,FFmpeg 要做的是直接封装成 WAV,不需要做任何重采样或转码。先分配输出上下文并创建音频流:
AVFormatContext* fmtCtx = nullptr; avformat_alloc_output_context2(&fmtCtx, nullptr, nullptr, "output.wav"); AVStream* stream = avformat_new_stream(fmtCtx, nullptr); // 关键:参数必须与 WASAPI GetMixFormat 的结果保持一致 AVCodecParameters* par = stream->codecpar; par->codec_type = AVMEDIA_TYPE_AUDIO; par->codec_id = AV_CODEC_ID_PCM_S16LE; par->sample_rate = sampleRate; // 从 GetMixFormat 读出 par->channels = channels; // 从 GetMixFormat 读出 par->format = AV_SAMPLE_FMT_S16; stream->time_base = (AVRational){1, sampleRate}; avio_open(&fmtCtx->pb, "output.wav", AVIO_FLAG_WRITE); avformat_write_header(fmtCtx, nullptr);这里有一个容易忽略的细节:WASAPI 的GetMixFormat返回的可能是WAVEFORMATEXTENSIBLE结构,声道数和 bits 位深不一定是你想象的 44100/16,所以程序必须动态读取格式,不能写死常量。
每拿到一块 PCM 数据后,把它包装成一个 AVPacket 写入:
AVPacket pkt; av_new_packet(&pkt, dataLen); memcpy(pkt.data, pcmData, dataLen); pkt.stream_index = 0; pkt.pts = framesWritten; // 累计写入的采样帧数 pkt.dts = framesWritten; pkt.duration = framesThisPacket; av_interleaved_write_frame(fmtCtx, &pkt); av_packet_unref(&pkt); framesWritten += framesThisPacket;用累计采样帧数作为 PTS,并且流的 time_base 是{1, sampleRate},这样 PTS 数值除以采样率就正好是秒数。WAV muxer 会自动在文件头部写入正确的数据长度,不需要我们自己手工回填。
4.3 停止时怎么把 WAV 收尾
停止录制时,先让 WASAPI 停止采集:
audioClient->Stop();然后把环形队列里残余的数据全部写盘,再调用 FFmpeg 的收尾接口:
av_write_trailer(fmtCtx); avio_close(fmtCtx->pb); avformat_free_context(fmtCtx);由于没有使用编码器,也不存在编码缓冲 flush 的问题,PCM 数据都是即时写入的。如果做的是 MP3/AAC 这类有损压缩格式,停止时还必须调用编码器 flush,把那部分余量数据吐出来,否则文件尾部会缺一段。
5. 实测后的坑与解决:安静文件、爆音、设备切换这些我都遇到过
5.1 录出来的文件全是 0 字节或开头静音
第一次跑通的时候,我兴奋地打开录音文件,结果听到的是几秒安静后才开始说话。问题出在 WASAPI 启动后最初的几个音频包可能全是静音占位包,或者缓冲区内还没有真正填充数据。
解决方法是:正式开始处理数据前,跳过前几个空包,或者检测到第一个非静音包才开始写文件。另外一个更稳妥的做法是启动采集后先丢弃前 100ms 的数据,因为这段数据往往是不完整的。判断静音包不能只看数据指针,WASAPI 还会通过 flags 标志AUDCLNT_BUFFERFLAGS_SILENT来提示“这段是静音”,这时候缓冲区里的数据可能是无效的,处理方式取决于你的需求——如果连安静部分也要保留,就手动生成相同帧数的全 0 PCM 数据写入文件;如果不需要这部分,直接忽略。
5.2 一边用扬声器一边录会带回声
Loopback 采集的是系统混音后的输出流,也就是任何正在播放到默认输出设备的声音都会被采进去。如果你录音时开着扬声器外放,麦克风又把外放声音收了进来,那么录出来的文件里既有系统声音也有扬声器外放的“回音”,效果非常糟糕。
这个问题不是音频采集代码能解决的,它属于音频处理里的回声消除范畴。实际项目里我会先建议客户录音时戴耳机;如果产品必须支持扬声器外放场景,就要引入 AEC 处理链路。注意,FFmpeg 本身不自带实时回声消除模块,要接第三方库或者自己写实时信号处理,复杂度会提升一个档次。
5.3 系统里改采样率后,录音音调变了
有次测试发现,同一套程序在 A 电脑上录音正常,在 B 电脑上录出来像是“慢速播放”的声音。排查下来发现是焦点设备默认采样率不同:A 电脑是 44100Hz,B 电脑是 48000Hz。我代码里写死了参数,导致 WASAPI 给的是 48k 数据,FFmpeg 却按 44.1k 的 time_base 去解析,音调自然就变了。
正确做法是初始化时用GetMixFormat读到的实际采样率和声道数,构建 FFmpeg 音频流时全部动态赋值。如果后续想把不同设备的录音统一成固定格式,需要接入重采样器,否则不要轻易对 PCM 参数做假设。
5.4 UI 刷新导致录音卡顿
我做了一个波形显示功能,起初是 UI 线程定时器去读采集线程的数据数组,结果发现录音里会偶尔卡顿。原因是 UI 刷新和采集线程访问同一块内存,虽然加了锁,但 UI 频繁加锁导致采集线程拿不到缓冲区的写入机会,音频包没有及时取走,就丢包了。
推荐的方案是:采集线程只负责把数据写进环形队列,同时用一个线程安全的方式发出一份“电平值快照”给 UI。UI 只管画电平条或波形,不碰真正的 PCM 数据。如果要做频谱,也是在采集线程内部做算法处理,只把处理结果发出来,这才是 Qt 信号槽的正确用法。
| 问题 | 现象 | 根因 | 解决 |
|---|---|---|---|
| 开头静音 | 录音前几秒空白 | WASAPI 启动后缓冲未就绪 | 丢弃前 100ms 或首个非静音包再开始写 |
| 扬声器回声 | 麦克风录进外放声 | Loopback 自带混音回环 | 用耳机,或者引入 AEC |
| 音调变化 | 慢速或高速播放感 | 采样率写死与设备不符 | 动态读取 GetMixFormat |
| 录音卡顿 | 偶发爆音 | UI 跨线程读音频数据 | 只传输电平值,不传输 PCM |
6. 再往前走一步:时长统计、码率换算和转 MP3/AAC
6.1 时长统计:靠帧数而不是靠时钟
录音时长最靠谱的计算方式是累计写入的音频帧数除以采样率。因为录制过程中如果发生了丢包,按系统时钟计算的时长和实际音频数据的时间长度会对不上;而按帧数计算,得到的永远是正确的媒体时长。前面代码里的framesWritten变量在停录时除以sampleRate,就能得到秒数:
录制时长 = framesWritten / sampleRate这个时长可以直接显示在 UI 上,也可以写进日志。有一点要注意:WAV 文件的时长和文件体积之间也存在换算关系,但文件体积受 WAV header 和可能的填充字节影响,不能直接用文件大小反推时长,音频应用里还是以帧数为主。
6.2 转成 MP3 或 AAC 需要引入编码器
直接用 WAV 存储是无损的,但对硬盘空间不太友好。以 48k/16bit/双声道为例,一小时要占用约 660MB,很多应用场景是撑不住的。要压缩成 MP3 或 AAC,就得在 FFmpeg 封装链路里加编码器:创建AVCodecContext,选择对应的有损编码器,把 PCM 送进去编码后再封装。
实际操作上,我更推荐两步走:先直接用这套 WASAPI + FFmpeg 方案录制无损 WAV 作为原始素材,后期再用 FFmpeg 命令行转码成目标格式。这样采集链路保持简单稳定,压缩逻辑后置,出了问题也好排查——先确认录音没问题,再确认转码参数对不对,不要把所有变数都堆在一个链路里。
6.3 还能扩展的点:设备选择、分段录制、事件触发
这套架构往上扩展的空间还挺大。比如多设备选择,可以枚举所有输出设备而不是只用默认设备;分段录制,可以按固定时长或者文件大小自动切割,每次切割时重新avformat_write_header和av_write_trailer;事件触发,比如检测到某个外部信号就自动开始录,再收到信号就停止。这些扩展不影响底层 WASAPI 和 FFmpeg 的核心逻辑,只要环形队列和线程生命周期保持稳定就行。
提示:如果要做断线重连,建议把“设备初始化”和“设备读取”拆成两个阶段。初始化失败时重试枚举设备,读取失败时只重置采集端,不要整个线程都退出,否则用户切一次声音设备,录音就悄悄停了。
我个人做完这套方案后最大的体会是,Windows 音频采集的核心难点不在 API 本身,而在于“数据连续性”。单看每个模块、每段代码都挺简单,但只要你让某一个环节出现阻塞、格式不一致、或者生命周期没理清,录音就会以各种诡异的方式给你颜色看。我的建议是:先把“采集 → 队列 → 封装”这条管道用最小的代码量跑通,确认 WAV 文件从头到尾都是连续可听的,再去加 UI、转码、设备切换这些花活。管道稳了,剩下的事都好说。