news 2026/10/20 14:50:19

基于FFmpeg API的摄像头与麦克风采集编码实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于FFmpeg API的摄像头与麦克风采集编码实践

简介:面向需要基于FFmpeg框架实现摄像头与麦克风采集的开发者,这份源码工程完整演示了借助DShow设备层绕过DirectShow复杂细节,完成设备枚举、音视频采集、编码和录制。资源包共162个文件,压缩后22.75MB,以102个.h头文件和11个.cpp实现文件为主,并附有dll、lib和可直接运行的exe程序,便于边读代码边验证,已有1304人学习下载。内容分三部分:先用ffmpeg.exe命令行枚举DShow设备并采集摄像头图像,建立直观认识;再介绍采集程序的功能与用法;最后剖析采集、编码、封装和录制各模块的实现要点。对希望摆脱DirectShow束缚、快速上手FFmpeg采集的开发者,这份资源提供了清晰的工程结构和可复用源码,能够有效缩短开发周期。

1. 用 FFmpeg API 采集摄像头与麦克风:为什么放着现成的命令行不用,要自己写代码

做音视频采集这件事,大多数人第一反应是拿 ffmpeg 命令行拼一段参数就完事。但现实场景里往往没这么顺利:做设备巡检的同事需要把摄像头画面实时叠加时间戳再推流,做课堂录制的朋友要求自动检测摄像头热插拔、断线自动重连,做本地工具的需要把采集到的 H.264 裸流和 AAC 音频封进自定义容器——这些靠命令行要么得写复杂的 -filter_complex 脚本,要么根本做不到。这篇笔记要讲的这个项目,就是一套用 FFmpeg C API 直接从摄像头抓视频帧、从麦克风抓音频 PCM,再编码封装成 MP4 文件的完整实现。它适合那些已经玩过命令行、想往底层 API 走一步的开发者,也适合需要把采集能力嵌进自己的 C/C++ 程序里的人。

2. 整体架构:从设备到文件的采集链路设计

2.1 线程模型与数据流向

这个项目在设计上不算复杂,但有一个关键决策:视频采集和音频采集放在两个独立线程里跑,共享一个输出上下文。为什么不像命令行那样单线程顺序处理?因为摄像头和麦克风的采集节奏天然不同步,视频帧率通常是 25 或 30fps,而音频设备每次回调送过来的 PCM 数据块时长不固定。如果串行处理,先等视频帧再等音频块,缓冲区的堆积会肉眼可见地增加延迟,录制出来的视频会带着越来越大的音画错位。

数据流向是这样的:视频线程从摄像头设备读到原始帧,解码成 YUV,再交给编码器输出 H.264;音频线程从麦克风设备读 PCM,做完重采样交给编码器输出 AAC。两条流汇合到复用器(muxer),由它负责按时间戳把包写进 MP4 文件。两个线程之间没有直接的数据交换,唯一的共享资源是输出文件的 AVFormatContext,所以加锁的压力很小,只有写包那一步需要保护。

2.2 关键数据结构与初始化顺序

整个项目围绕三个核心结构体展开:AVFormatContext 负责设备上下文和最终输出文件的封装上下文,AVCodecContext 管编解码器参数,AVPacket 是设备读出来的原始数据包。视频初始化顺序是:先注册设备后端并用 avformat_open_input 打开摄像头,再 avformat_find_stream_info 探测流信息,拿到视频流之后用 avcodec_find_decoder 找到解码器并打开,接着用 sws_getContext 准备像素格式转换器,最后创建编码器上下文准备输出。

音频路径类似,唯一区别是打开设备后要立刻调用 av_read_play 让设备开始采集,不然某些设备会一直处于暂停状态。两个线程各自维护自己的 pts(显示时间戳),视频帧的 pts 按帧号累加,音频帧的 pts 按「已处理的采样数除以采样率」换算,最后在写包之前统一用 av_packet_rescale_ts 转换到输出流的时间基上。这个设计直接避免了音画不同步的经典问题。

3. 视频采集核心:摄像头设备打开与帧格式转换

3.1 从设备名到 AVFormatContext

视频采集第一步是拿到设备句柄。Windows 上用 dshow 后端,设备名一般是「Integrated Camera」这一类;Linux 上用 v4l2,设备路径是 /dev/video0;macOS 用 avfoundation 加设备索引。这个项目的做法是把设备名做成参数,由调用方传入,适配层根据编译宏选择后端。核心代码:

AVFormatContext *fmt_ctx = NULL; AVInputFormat *input_fmt = NULL; #if defined(_WIN32) input_fmt = av_find_input_format("dshow"); #elif defined(__linux__) input_fmt = av_find_input_format("v4l2"); #endif // 摄像头设备大概率直接给 640x480 的 YUYV,不需要额外指定 AVDictionary *options = NULL; av_dict_set(&options, "framerate", "30", 0); av_dict_set(&options, "video_size", "1280x720", 0); int ret = avformat_open_input(&fmt_ctx, device_name, input_fmt, &options); if (ret < 0) { char errbuf[128]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "open camera failed: %s\n", errbuf); return -1; } ret = avformat_find_stream_info(fmt_ctx, NULL); if (ret < 0) { fprintf(stderr, "find stream info failed\n"); return -1; }

这段里最容易错的点是 AVInputFormat 必须通过 av_find_input_format 显式查找,而不是直接传 NULL。传 NULL 时 FFmpeg 虽然会自动探测,但面对 dshow 的虚拟设备经常探测失败,返回的错误码又难查到具体原因。第二个值得注意的细节是 av_dict_set 设置的 video_size 参数,它要的是宽x高的字符串格式,如果设备不支持 1280x720,后面的 avformat_open_input 会直接失败,所以这个参数不能硬编码,最好先查询设备能力再填。

3.2 解码器初始化与像素格式转换

拿到 fmt_ctx 之后要找到视频流索引,这个索引后面会频繁用来判断 AVPacket 该走哪条处理链。项目里用了一个简单方式:先遍历所有流,找到 type 是 AVMEDIA_TYPE_VIDEO 的流,然后拿它的编解码参数去打开解码器。之前见过有人用 fmt_ctx->streams[0] 假设第一条流就是视频,在广电采集卡上翻过车,因为有的卡会先暴露音频流。

int video_stream_index = -1; for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) { if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { video_stream_index = (int)i; break; } } const AVCodec *decoder = avcodec_find_decoder(fmt_ctx->streams[video_stream_index]->codecpar->codec_id); AVCodecContext *dec_ctx = avcodec_alloc_context3(decoder); avcodec_parameters_to_context(dec_ctx, fmt_ctx->streams[video_stream_index]->codecpar); dec_ctx->thread_count = 1; // 采集场景要低延迟,关掉多线程解码 ret = avcodec_open2(dec_ctx, decoder, NULL);

解码器打开之后,设备读上来的包是 YUYV422 或者 MJPEG,编码器输入要的是 YUV420P,这中间差一道像素格式转换。项目里调用 sws_getContext 做这件事,转换上下文在循环外面创建一次,后面每个视频帧都复用,避免每帧都重新申请内存。转换目标尺寸直接沿用解码后的宽高,不需要缩放,省掉一层滤镜。

struct SwsContext *sws_ctx = sws_getContext( dec_ctx->width, dec_ctx->height, dec_ctx->pix_fmt, // 源格式 dec_ctx->width, dec_ctx->height, AV_PIX_FMT_YUV420P, // 目标格式 SWS_BILINEAR, NULL, NULL, NULL); // 每帧转换时输出到编码器需要的 AVFrame uint8_t *dst_data[4] = {0}; int dst_linesize[4] = {0}; av_image_alloc(dst_data, dst_linesize, dec_ctx->width, dec_ctx->height, AV_PIX_FMT_YUV420P, 32); sws_scale(sws_ctx, src_frame->data, src_frame->linesize, 0, dec_ctx->height, dst_data, dst_linesize);

转换后的数据塞进一个新创建的 AVFrame 里,设置 format 为 AV_PIX_FMT_YUV420P,width 和 height 从解码上下文取,pts 按帧序号乘时间基去算。这里有个初中阶段容易忽略的点:YUV420P 的 Y 平面大小是宽乘高,U 和 V 平面分别是四分之一,av_image_alloc 返回的 linesize 可能带对齐填充,所以拷贝数据不能整块 memcpy,要用 linesize 逐行拷。

3.3 编码器配置与 H.264 输出

视频编码器用的 libx264,配置上走的是低延迟路线:preset 设为 ultrafast,tune 设为 zerolatency,gop 大小按帧率的两倍设置。这些参数直接作为 AVOption 传给编码器上下文。编码器打开后,每个转换好的 AVFrame 调用 avcodec_send_frame,循环 avcodec_receive_packet 取出编码后的 AVPacket,然后做时间基转换后写入输出文件。

AVCodecContext *enc_ctx = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_H264)); enc_ctx->width = video_width; enc_ctx->height = video_height; enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P; enc_ctx->time_base = (AVRational){1, 30}; enc_ctx->framerate = (AVRational){30, 1}; enc_ctx->gop_size = 60; av_opt_set(enc_ctx->priv_data, "preset", "ultrafast", 0); av_opt_set(enc_ctx->priv_data, "tune", "zerolatency", 0); ret = avcodec_open2(enc_ctx, NULL, NULL);

注意时间基是 {1, 30},意思是每帧的时间长度是 1/30 秒,pts 从 0 开始每个视频帧加 1。这比直接用微秒精度的 time_base 简单,也不容易超精度。gop_size 设成 60 意味着每两秒一个关键帧,这个值看使用场景,如果要做实时流传输,关键帧间隔大了会导致播放器换台黑屏时间过长,但存本地文件无所谓。

4. 音频采集与同步:从麦克风 PCM 到 AAC 编码

4.1 音频设备打开与参数确认

音频这条链路的复杂性不亚于视频。dshow 下麦克风设备名通常是「Microphone Array」这类描述,打开方式和视频几乎一样,只是 AVInputFormat 不同。音频流找到后,第一步要确认它的采样格式、采样率和声道数,市面上一半以上的 USB 麦克风上报的参数跟实际采集到的数据不一致,常见的有名义上 44100Hz 实际输出 48000Hz,以及声称立体声但只有左声道有数据。

ret = avformat_open_input(&fmt_ctx, mic_name, input_fmt, NULL); ret = avformat_find_stream_info(fmt_ctx, NULL); int audio_stream_index = -1; for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) { if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { audio_stream_index = (int)i; break; } } AVCodecParameters *params = fmt_ctx->streams[audio_stream_index]->codecpar; printf("sample_rate=%d channels=%d format=%d\n", params->sample_rate, params->channels, params->format);

FFmpeg 的 AVSampleFormat 枚举值不是按位数单调排列的,直接打印数字没法读,建议写个辅助函数把枚举转成字符串,否则一眼看过去分不清是 AV_SAMPLE_FMT_S16 还是 AV_SAMPLE_FMT_FLT。先确认参数,再决定后面怎么重采样,比拿设备原格式硬编码靠谱得多。

4.2 重采样与音频 pts 换算

麦克风原始数据是 PCM S16 的,而 AAC 编码器接收的格式通常是 FLTP(float planar),声道布局也可能从交错变成独立平面。这一步必须用 swr_convert 处理。项目里把重采样上下文初始化成固定输出:48000Hz、双声道、FLTP 格式,这样不管插什么麦克风都能统一到同一条编码链路。

SwrContext *swr_ctx = swr_alloc_set_opts(NULL, AV_CH_LAYOUT_STEREO, AV_SAMPLE_FMT_FLTP, 48000, // 输出格式 av_get_default_channel_layout(params->channels), // 输入声道布局 params->format, params->sample_rate, // 输入原格式 0, NULL); swr_init(swr_ctx); // 换算输出采样数,分配临时缓冲区 int max_out_samples = swr_get_out_samples(swr_ctx, params->frame_size); uint8_t **dst_data = (uint8_t**)calloc(2, sizeof(uint8_t*)); av_samples_alloc(dst_data, NULL, 2, max_out_samples, AV_SAMPLE_FMT_FLTP, 32); int converted = swr_convert(swr_ctx, dst_data, max_out_samples, (const uint8_t**)src_frame->data, src_frame->nb_samples);

音频 pts 的算法跟视频完全不同,不能靠帧序号。正确做法是维护一个全局采样计数:pts = 已处理的采样总数 / 采样率。每次拿到重采样后的音频帧,就把 nb_samples 累加到计数上,这样即使重采样改变采样率也不会算错。转换成 AVRational 时间基时,分子用已累计的采样数,分母用输出采样率 48000,FFmpeg 内部会把它归约成合适的数值。

4.3 双线程汇合与写文件

两个线程生产出来的包最后都汇聚到同一个写文件函数。这个函数做三件事:根据包类型找到对应的输出流,av_packet_rescale_ts 把时间基从流的时间基转成输出容器的整体时间基,然后加锁调用 av_interleaved_write_frame。之所以用 interleaved 版本而不是 av_write_frame,是因为它内部会做交错排序,保证 MP4 文件里音频包和视频包按时间顺序交替写入,播放器顺序读就不会遇到时间戳跳跃。

static void write_packet(void *lock, AVFormatContext *out_ctx, AVStream *out_stream, AVPacket *pkt) { pkt->stream_index = out_stream->index; av_packet_rescale_ts(pkt, out_stream->time_base, out_ctx->streams[out_stream->index]->time_base); pthread_mutex_lock((pthread_mutex_t*)lock); int ret = av_interleaved_write_frame(out_ctx, pkt); pthread_mutex_unlock((pthread_mutex_t*)lock); if (ret < 0) { fprintf(stderr, "write frame failed: %d\n", ret); } av_packet_unref(pkt); }

输出文件的初始化在采集开始前完成:avformat_alloc_output_context2 创建 MP4 封装器,avio_open 打开磁盘文件,然后分别用 avformat_new_stream 添加两路输出流,把编码器的参数拷贝过去。最后 avformat_write_header 写文件头。整个录制循环的退出条件是用户按 Q 键或者收到 SIGINT 信号,退出前要调用 av_write_trailer 写文件尾,否则 MP4 文件没有 moov 索引,播放器打不开。

5. 常见问题排查:黑屏、花屏、音画不同步的现场与解法

5.1 摄像头打开成功但解码全是黑帧

现象:程序正常跑,日志里能看到每一帧都从设备读出来了,但输出的视频文件整段黑屏。

原因排查下来有两个高发点:一是像素格式转换后 AVFrame 的 data 指针和 linesize 没有正确从转换缓冲拷贝,导致编码器拿到的全是空数据;二是解码器打开的线程数大于 1,某些 V4L2 设备的多线程解码模式会有竞态,帧数据被覆盖。

解决:解码线程数固定为 1;转换完用 av_frame_get_buffer 重新分配输出帧,再用 av_image_copy 配合 linesize 逐平面拷贝,不要自己拿 malloc 拼数据。

5.2 录制出来的视频音画不同步

现象:视频画面正常,但声音对不上,要么嘴型快于画面,要么反过来。这个项目里最容易出问题的就是 pts 换算,常见的错误写法是把音频和视频统一用系统时钟打时间戳,结果设备缓冲区延迟不同,导致偏差。

原因:音频用的是「采样计数除以采样率」,视频用「帧号乘以帧时长」,两者都必须以各自流的时间基表达,在写包前再做 rescale。如果音频直接沿用视频的时间基,或者干脆给音频 pts 赋 video pts 的值,就必不同步。

解决:音频线程维护独立的 sample_count,每次从重采样器拿到 nb_samples 就累加;视频线程维护 frame_index,每次编码前加一。然后在 av_interleaved_write_frame 之前统一 rescale 到输出流时间基。

5.3 退出程序时文件损坏打不开

现象:程序 Ctrl+C 强杀后,输出 MP4 文件体积正常但播放器提示文件损坏。

原因:MP4 的索引 moov box 写在文件末尾,av_write_trailer 没跑就把文件关了,相当于丢了索引。之前试过用 SIGINT 捕获信号去调 av_write_trailer,但有时第二个信号进来导致二次写入,反而把文件写坏。

解决:录制循环跑完后先 av_write_trailer,再关闭编码器和设备上下文。给信号处理函数设一个 atomic 标志位,主循环检测到标志位就退出循环,不要在信号处理器里直接调用 FFmpeg 的写函数,那不安全。

5.4 USB 麦克风采样率上报错误导致声音变调

现象:按设备上报的 44100Hz 初始化重采样器,录出来的声音音调整体偏高。

原因:设备实际输出的采样率是 48000Hz,但驱动上报的是 44100Hz,重采样器按错误的比例换算,相当于改变了音调。

解决:项目里统一把重采样输出设为 48000Hz,同时写一段校准逻辑:先抓几秒裸数据算零交叉率,反推实际采样率,再用实际值初始化 swr。后来发现很多产线的 USB 声卡都有这毛病,现在默认音频输出就锁 48000,不做跟随。

5.5 同一帧时间戳倒挂导致编码器报错

现象:v4l2 设备在弱光环境下偶发丢帧,丢帧后下一个包的 pts 比前一个还早,libx264 直接报 non-strictly-monotonic PTS,中断录制。

原因:设备端时间戳用的是单调时钟,某些驱动在缓冲区满时会把未完成的帧时间戳清零或回拨。

解决:在代码里做一次单调化修正,每次收到帧先比较 pts 是否小于上次记录的值,如果小于,就以上次值加一覆盖。这层保护可以统一应对摄像头和麦克风两边的时间戳异常,写包前再 rescale 就不怕倒挂了。

6. 二次开发:把采集 Demo 变成可用的录制工具

6.1 改造点一:从写文件到推流

这个项目最初是录 MP4 文件的,但很多人拿它改做直播采集端。改法不复杂:把开路函数里 avformat_alloc_output_context2 的后缀参数从 "mp4" 改成 "flv",avio_open 的目标从本地文件名改成 rtmp 地址,其余初始化几乎原样保留。注意一个差异:MP4 是 seekable 容器,而 RTMP 直播流不可 seek,所以 avformat_write_header 之前要设置 AVFMT_FLAG_NOBUFFER 标志,否则写入 RTMP 时延迟会越滚越大。我做这个改动时发现,直播场景的 GOP 建议缩到一秒而不是两秒,等关键帧的卡顿感会明显减弱。

另一个值得加的是视频帧的时基从 {1, 30} 改成 {1, 1000},这样时间戳精度到毫秒,音视频同步的容错空间更大,代价是编码器处理大时间基数值时要多几次归约运算,对现代 CPU 来说可以忽略。

6.2 改造点二:接入屏幕捕获作为第二路视频源

项目里摄像头和麦克风是两条独立线程,但如果想改成「摄像头+屏幕录制」双视频源,就要把线程模型从两线程扩成三线程。屏幕捕获在 Windows 上可以用 gdigrab 设备名「desktop」打开,帧率设置成 10fps 就够,因为桌面内容大多数时间变化不大,高帧率只会膨胀文件体积。屏幕流的 pts 不能和摄像头一样按帧号乘帧时长,因为你不能保证屏幕捕获的每帧间隔都稳定,正确做法是用每帧接收时刻的系统时钟减掉启动时刻,转成微秒再换算成时间基。这个思路同样适用于给现有摄像头流加时间戳水印:编码前用 libswscale 把文字烙进 YUV 数据,pts 不受影响。

6.3 改造点三:增加摄像头热插拔自动重连

项目在初始化时打开摄像头失败就直接退出,但实际部署时摄像头可能被拔掉又插上。我的处理思路是在采集线程里检测 av_read_frame 返回的错误码,包装成「设备断开」信号,主线程收到后尝试重新 avformat_open_input。注意重新打开后要重建解码器上下文和 sws 上下文,不能复用旧实例。这个重连逻辑里最容易出的问题是不释放旧的 fmt_ctx 就直接调用 avformat_open_input,内存泄漏倒是小事,设备驱动会因为句柄未释放直接拒绝再次打开。从那以后我每次做设备采集相关的代码,都会在重连逻辑里强制走一遍「释放旧句柄再申请新句柄」的流程,哪怕只是临时修 bug 也不再跳过。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 14:50:06

CANN生态模型可视化调试工具:model-inspector核心解析与实战

写 AI 模型调试的人&#xff0c;应该都有过这种体验&#xff1a;模型在训练或推理设备上跑出来的结果不对&#xff0c;但你在宿主机的日志里翻半天&#xff0c;只能看到一行行张量 shape 和几个让人摸不着头脑的错误码。你根本看不到模型内部长什么样&#xff0c;算子之间的数据…

作者头像 李华
网站建设 2026/10/20 14:49:52

WSL2+Docker 搭建 ComfyUI 环境:从入门到避坑

如果你最近开始折腾 ComfyUI&#xff0c;大概率体会过什么叫做“环境比模型更难搞”。Python 版本要卡、CUDA 要对号、PyTorch 还得和显卡驱动匹配&#xff0c;装错一版就能让你白折腾好几天。我一度在 Windows 裸机环境下跟各种 DLL 报错斗智斗勇&#xff0c;后来果断切到 WSL…

作者头像 李华
网站建设 2026/10/20 14:49:41

用信息论重构智能体的模型组合与算力分布

1. 别急着堆模型&#xff1a;一次让我重新审视"模型组合和算力分布"的失败经历我最早接触智能体的时候&#xff0c;和大多数团队的做法一样&#xff1a;按功能把任务拆成几个子模块&#xff0c;给每个子模块分一个"感觉最合适"的模型。意图识别不够准&…

作者头像 李华
网站建设 2026/10/20 14:49:16

mysql-connector-java与mysql-connector-j:MySQL JDBC驱动坐标迁移全解析

在Java后端开发的日常里&#xff0c;有一个特别有意思的现象&#xff1a;网上搜JDBC驱动的配置教程&#xff0c;有的写 mysql:mysql-connector-java &#xff0c;有的写 com.mysql:mysql-connector-j &#xff0c;两个看上去只差了一小截&#xff0c;但用错了就可能让Maven…

作者头像 李华
网站建设 2026/10/20 14:47:21

C++内存泄漏排查实战:用Valgrind Memcheck定位new与delete的失配问题

记得有一次&#xff0c;我接手了一个已经跑了两年多的后台服务&#xff0c;功能看着都正常&#xff0c;就是内存占用像温水煮青蛙一样&#xff0c;每天涨一点点&#xff0c;两周后稳稳触顶&#xff0c;然后 OOM 重启。查了一整天&#xff0c;valgrind也跑了&#xff0c;gdb也挂…

作者头像 李华
网站建设 2026/10/20 14:46:53

Codeblocks下载安装汉化全指南:从官网到中文界面避坑详解

折腾过 C 语言入门的朋友&#xff0c;大概率都绕不开 Codeblocks 这个名字。可很多人卡在了第一步&#xff1a;官网下载慢、装完发现没带编译器、汉化包找不到地方放。这篇教程就把 Codeblocks 下载、安装、汉化整条链路掰开揉碎讲清楚&#xff0c;从官网下载到界面变中文&…

作者头像 李华