简介:这是一份基于 FFmpeg 与 SDL2 实现的音频播放示例工程,面向已具备 C/C++ 基础、希望理解音视频解码与播放链路的开发者。工程以播放 MP3 文件为目标,采用链表队列在解码线程与播放回调之间传递 PCM 数据,便于读者观察数据生产与消费的节奏控制,也适合作为学习 FFmpeg 解码、SDL2 音频回调等知识点的练手项目。压缩包共 231 个文件,约 15.45MB,包含大量 .h 头文件、.lib 静态库与 .dll 动态库以及 .cpp 源码、.sln 工程文件和少量测试音频,依赖库与示例代码相对完整,解压后可按工程结构梳理模块划分。已有 1124 人学习下载,适合需要一份可运行参考实现、对照理解音频播放队列与依赖配置的读者。
1. 先别急着写回调:FFmpeg+SDL2 播放音频的四个数据关口
一套能编译的 ffmpeg SDL2 PCM Player 源码,最容易让人误判的地方不是解码失败,而是日志里每一步都“成功”,耳朵里却什么都没有。我见过某开发者拿着工程调了一下午,avcodec_receive_frame有输出、SDL_OpenAudioDevice也返回了设备 ID,最后发现喂给 SDL2 的字节数按“样本数”算,实际只送了四分之一的音频数据。这套源码解决的正是这条链路:用 FFmpeg 把容器里的音频解成 PCM,用 SDL2 把 PCM 送到声卡,中间把采样率、声道数、样本格式对齐。它适合想搞懂音频播放底层链路的 C/C++ 开发者,也适合做语音回放、本地播放器、直播伴音这类需要自己控缓冲的人。前提是你得接受一件事:音频播放没有“玄学”,只有格式契约和线程边界。
2. 拆开播放链路:av_read_frame 到 AVFrame 的三次格式转换
2.1 为什么“直接喂 MP3 给 SDL2”一定翻车
SDL2 的音频设备只认裸 PCM。它不知道什么是 MP3 帧头、AAC ADTS、容器里的时间戳,更不会帮你做声道布局转换。把压缩包直接塞进SDL_QueueAudio,声卡只会把压缩数据当成振幅采样,出来的是白噪声,音量还可能直接拉满。FFmpeg 在这里承担的是“解封装 + 解码”两段:AVFormatContext把文件拆成一条条AVPacket,AVCodecContext把包解成AVFrame,AVFrame里才是 PCM 样本。很多人把AVFrame直接当成“可以播放的数据”,忽略了它可能还是 planar 浮点格式,甚至采样率和声卡不一致。真正能交给 SDL2 的,是经过swr_convert对齐后的 interleaved S16 缓冲区。这个顺序不能省:解封装 → 解码 → 重采样/格式转换 → 入队。
2.2 用 av_find_best_stream 定位音频流
打开文件之后第一件事不是急着读包,而是确认音频流索引。一个 MP4 里可能有一条视频、两条音轨、一条字幕,av_read_frame返回的包不属于音频流时直接丢掉,否则解码器会收到视频包并返回错误。av_find_best_stream会按解码器可用性挑一条默认音轨,比手工遍历streams[i]->codecpar->codec_type稳。
AVFormatContext *fmt = NULL; if (avformat_open_input(&fmt, path, NULL, NULL) < 0) { fprintf(stderr, "open input failed\n"); return -1; } if (avformat_find_stream_info(fmt, NULL) < 0) { fprintf(stderr, "find stream info failed\n"); return -1; } // 让 FFmpeg 自己挑一条可解码的音频流,避免选中字幕或视频 int audio_idx = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0); if (audio_idx < 0) { fprintf(stderr, "no audio stream\n"); return -1; } AVCodecParameters *par = fmt->streams[audio_idx]->codecpar; const AVCodec *codec = avcodec_find_decoder(par->codec_id); AVCodecContext *dec = avcodec_alloc_context3(codec); avcodec_parameters_to_context(dec, par); if (avcodec_open2(dec, codec, NULL) < 0) { fprintf(stderr, "open decoder failed\n"); return -1; }这段代码里avformat_open_input负责打开容器,avformat_find_stream_info会预读一段数据把流参数补齐。av_find_best_stream的第三个参数是期望的流索引,传 -1 表示不指定;第四个参数是关联流,音频一般不用。avcodec_parameters_to_context把容器里的编码参数复制进解码器上下文,少这一步会出现sample_rate=0或channels=0。常见错误是avcodec_alloc_context3(NULL)之后不复制参数,解码器能打开但解出来的帧参数是空的,后面重采样直接崩。
2.3 解码循环的 send/receive 写法
FFmpeg 新版解码 API 是“送包/收帧”分离的,avcodec_send_packet返回EAGAIN不是失败,而是让你先把解码器里缓存的帧收完。很多老代码还在用avcodec_decode_audio4,搬到新工程里编译不过,或者勉强编译后出现“一包只出一帧、尾帧丢失”的问题。正确的循环是:送一个包,尽量收帧;收完再送下一个包;文件结束后送NULL包 flush 解码器。
AVPacket *pkt = av_packet_alloc(); AVFrame *frame = av_frame_alloc(); while (av_read_frame(fmt, pkt) >= 0) { if (pkt->stream_index == audio_idx) { int ret = avcodec_send_packet(dec, pkt); if (ret < 0 && ret != AVERROR(EAGAIN)) { fprintf(stderr, "send packet failed: %d\n", ret); } // 一个包可能对应多帧,必须循环收到 EAGAIN 为止 while ((ret = avcodec_receive_frame(dec, frame)) == 0) { // 这里进入重采样和入队,见下一节 av_frame_unref(frame); } if (ret != AVERROR(EAGAIN) && ret != AVERROR_EOF) { fprintf(stderr, "receive frame failed: %d\n", ret); } } av_packet_unref(pkt); } // 文件尾:送空包,把解码器里的延迟帧全部取出 avcodec_send_packet(dec, NULL); while (avcodec_receive_frame(dec, frame) == 0) { // 同样做重采样和入队 av_frame_unref(frame); }av_packet_unref必须每次循环调用,否则包内缓冲区不会释放,长音频跑几分钟内存就涨上去。av_frame_unref放在收帧循环里,避免帧内引用越积越多。AVERROR(EAGAIN)表示“当前没有更多帧,去送下一个包”,不是错误;AVERROR_EOF表示解码器已经 flush 完。尾帧 flush 经常被漏掉,表现是音频最后几百毫秒被截断,听感上像“话没说完就停了”。
2.4 从 AVFrame 提取 nb_samples 与采样率
AVFrame里的nb_samples是“每声道样本数”,不是字节数。立体声 S16 的一帧如果有 1024 个nb_samples,实际字节数是1024 * 2 声道 * 2 字节 = 4096。frame->sample_rate是解码器输出的采样率,可能和文件容器标称的不一样,必须以帧为准。frame->format是样本格式,常见的有AV_SAMPLE_FMT_FLTP(planar float)、AV_SAMPLE_FMT_S16(interleaved short),前者在 AAC 解码里非常常见。frame->ch_layout是新接口的声道布局,老接口用frame->channels;写兼容代码时按本机头文件选择,不要两套混用。
// 打印第一帧的真实参数,别猜 fprintf(stderr, "frame: nb_samples=%d rate=%d fmt=%d layout=%s\n", frame->nb_samples, frame->sample_rate, frame->format, frame->ch_layout.order == AV_CHANNEL_ORDER_NATIVE ? "native" : "other");这段打印建议在调试期一直留着。音频问题里有一半是“参数假设错了”:以为解码器输出 44.1 kHz,实际是 48 kHz;以为输出立体声,实际是单声道;以为格式是 S16,实际是 FLTP。把第一帧参数打出来,比后面拿耳朵猜快得多。
3. 重采样与格式对齐:swr_convert 的输出样本数怎么算
3.1 SwrContext 的输入输出参数从哪来
重采样器的输入参数必须来自AVFrame,输出参数必须来自 SDL2 实际打开的obtained规格。很多人把输出采样率写死成 44100,结果设备以 48000 打开,声音整体变慢、音调变低;反过来写死 48000 而设备是 44100,人声会变快变尖。正确做法是先打开 SDL2 设备,拿到have.freq、have.format、have.channels,再拿这三个值去初始化SwrContext。如果设备打开时允许了频率变化,have可能和want不同,必须按have来。
// out_rate/out_channels 来自 SDL 的 obtained 规格,不要写死 AVChannelLayout in_layout, out_layout; av_channel_layout_copy(&in_layout, &frame->ch_layout); av_channel_layout_default(&out_layout, out_channels); SwrContext *swr = NULL; int ret = swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, out_rate, &in_layout, frame->format, frame->sample_rate, 0, NULL); if (ret < 0 || swr_init(swr) < 0) { fprintf(stderr, "swr init failed\n"); return -1; }swr_alloc_set_opts2的参数顺序是“输出布局、输出格式、输出采样率、输入布局、输入格式、输入采样率”。swr_init必须调用,否则转换时返回EINVAL。in_layout从帧里复制,不能直接传&frame->ch_layout后让swr持有它,因为帧会被av_frame_unref释放。老接口用swr_alloc_set_opts加av_get_default_channel_layout,逻辑一样,只是声道布局用整数表示。两套接口不要混着写,编译能过但运行时会拿到错误的布局。
3.2 out_count 的公式与缓冲区分配
swr_convert的输出缓冲区要按“最大可能样本数”分配,不能按输入nb_samples原样分配。采样率从 44100 转到 48000,输出样本数会变多;重采样器内部还有延迟,swr_get_delay要把这部分算进去。标准公式是av_rescale_rnd(delay + in_samples, out_rate, in_rate, AV_ROUND_UP),其中delay用输入采样率表示。
int out_samples = (int)av_rescale_rnd( swr_get_delay(swr, frame->sample_rate) + frame->nb_samples, out_rate, frame->sample_rate, AV_ROUND_UP); uint8_t **out_data = NULL; int out_linesize = 0; av_samples_alloc_array_and_samples(&out_data, &out_linesize, out_channels, out_samples, AV_SAMPLE_FMT_S16, 1); int converted = swr_convert(swr, out_data, out_samples, (const uint8_t **)frame->extended_data, frame->nb_samples); if (converted > 0) { int out_bytes = av_samples_get_buffer_size(NULL, out_channels, converted, AV_SAMPLE_FMT_S16, 1); // 把 out_data[0] 里 out_bytes 字节送入 SDL 队列 enqueue_pcm(out_data[0], out_bytes); } av_freep(&out_data[0]); av_freep(&out_data);av_samples_alloc_array_and_samples分配的是“指针数组 + 数据区”,out_data[0]是实际样本缓冲区,out_data[1]在 packed 格式下为 NULL。释放时要先释放out_data[0],再释放out_data,顺序反了会泄漏。av_samples_get_buffer_size的最后一个参数align=1表示紧密打包,适合直接入队;如果写 0,某些平台会按对齐填充,字节数可能偏大。converted是实际转换出的每声道样本数,可能小于out_samples,字节数必须按converted算,不能按out_samples算,否则队列里会混入未初始化的尾部数据,听感上就是“滋啦”一声。
3.3 packed 与 planar 的拷贝差异
FFmpeg 解码出来的音频格式不一定是 interleaved。AAC 常见FLTP,每个声道一个平面;MP3 常见S16P或FLTP;PCM 文件可能是S16interleaved。swr_convert的输入用frame->extended_data可以同时兼容两种布局,输出统一成AV_SAMPLE_FMT_S16后就是 interleaved,SDL2 直接认。
| 输入格式 | 数据布局 | 常见来源 | 交给 swr 的写法 |
|---|---|---|---|
AV_SAMPLE_FMT_S16 | interleaved | WAV/PCM 文件 | frame->extended_data |
AV_SAMPLE_FMT_S16P | planar | 部分 MP3 解码器 | frame->extended_data |
AV_SAMPLE_FMT_FLTP | planar float | AAC 解码 | frame->extended_data |
AV_SAMPLE_FMT_FLT | interleaved float | 部分音效文件 | frame->extended_data |
表格里最容易踩的是“看到S16就以为可以直接 memcpy 给 SDL2”。如果声道数是 1 或 2,S16interleaved 确实可以直接送;但S16P是两个独立平面,直接送会把左声道当成立体声交织数据,右声道丢失或错位。统一走swr_convert最省心,代价是一次内存拷贝,对本地播放来说可以接受。
3.4 flush 尾帧与 EOF 处理
重采样器内部有缓存,文件结束后要再调一次swr_convert,输入样本数传 0,把残留样本挤出来。只 flush 解码器不 flush 重采样器,音频尾部会少几十毫秒,听感上像最后一个字被切掉。flush 的输出缓冲区同样按swr_get_delay估算,循环调用直到返回 0。
// 解码器 flush 完之后,再 flush 重采样器 for (;;) { int out_samples = (int)av_rescale_rnd(swr_get_delay(swr, out_rate), out_rate, out_rate, AV_ROUND_UP); if (out_samples <= 0) break; uint8_t **out_data = NULL; av_samples_alloc_array_and_samples(&out_data, NULL, out_channels, out_samples, AV_SAMPLE_FMT_S16, 1); int converted = swr_convert(swr, out_data, out_samples, NULL, 0); if (converted <= 0) { av_freep(&out_data[0]); av_freep(&out_data); break; } int out_bytes = av_samples_get_buffer_size(NULL, out_channels, converted, AV_SAMPLE_FMT_S16, 1); enqueue_pcm(out_data[0], out_bytes); av_freep(&out_data[0]); av_freep(&out_data); }这里swr_get_delay(swr, out_rate)用输出采样率取延迟,是因为输入已经结束,剩下的延迟要按输出时间轴换算。swr_convert的输入指针传NULL、输入样本数传 0 是官方约定的 flush 方式,不是“传空指针会崩”的野路子。flush 出来的字节同样要入队,且要等 SDL 队列播完再关闭设备,否则最后一段直接丢。
4. 把 PCM 交给 SDL2:回调队列、缓冲水位与设备参数
4.1 SDL_OpenAudioDevice 的 desired 与 obtained
SDL2 打开音频设备时给一个SDL_AudioSpec desired,设备返回一个obtained。如果allowed_changes传 0,SDL 必须完全满足 desired,否则打开失败;如果允许频率或格式变化,obtained可能和 desired 不同。很多工程在allowed_changes里开了SDL_AUDIO_ALLOW_FREQUENCY_CHANGE,但重采样仍然按 desired 的采样率写死,结果就是“设备以 48 kHz 打开,数据按 44.1 kHz 送”,声音变慢。
SDL_AudioSpec want, have; SDL_zero(want); want.freq = 48000; want.format = AUDIO_S16SYS; want.channels = 2; want.samples = 1024; // 回调每次要的样本数,影响延迟 want.callback = sdl_audio_cb; want.userdata = &audio_queue; // allowed_changes 传 0:强制设备匹配,匹配不上就报错,避免参数漂移 SDL_AudioDeviceID dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); if (dev == 0) { fprintf(stderr, "open audio device failed: %s\n", SDL_GetError()); return -1; } fprintf(stderr, "device: freq=%d fmt=%d ch=%d samples=%d\n", have.freq, have.format, have.channels, have.samples);want.samples是每次回调请求的样本帧数,1024 在 48 kHz 下大约 21 毫秒,兼顾延迟和回调频率;设成 256 延迟更低但回调更频繁,设成 4096 更稳但操作延迟明显。AUDIO_S16SYS表示本机字节序的 16 位有符号整数,跨平台时不要写AUDIO_S16LSB。have打出来是血泪经验:后面所有重采样参数都以have为准,不以want为准。
4.2 回调函数里只做 memcpy 和静音填充
SDL2 的音频回调跑在独立音频线程里,不能在里面做解码、不能malloc/free大块内存、不能长时间持锁、不能调用 FFmpeg。回调要做的只有三件事:把stream清零、从队列里拷数据、队列不够就保持静音。拷贝时注意len是字节数,不是样本数,也不是帧数。
typedef struct { uint8_t *buf; size_t cap; // 环形缓冲总字节数 size_t r, w; // 读写下标 size_t len; // 当前可读字节数 SDL_mutex *lock; SDL_atomic_t underrun; } AudioQueue; static void sdl_audio_cb(void *userdata, Uint8 *stream, int len) { AudioQueue *q = (AudioQueue *)userdata; SDL_memset(stream, 0, len); // 先补静音,避免爆音 SDL_LockMutex(q->lock); size_t n = q->len < (size_t)len ? q->len : (size_t)len; if (n == 0) { SDL_AtomicIncRef(&q->underrun); // 记录一次欠载,方便排查 } else { size_t first = q->cap - q->r; if (first > n) first = n; memcpy(stream, q->buf + q->r, first); if (n > first) { memcpy(stream + first, q->buf, n - first); } q->r = (q->r + n) % q->cap; q->len -= n; } SDL_UnlockMutex(q->lock); }SDL_memset(stream, 0, len)必须放在最前面。队列数据不够时,剩余部分保持 0,这是静音,不是“没数据”。不补零会让声卡播放上一次回调残留的缓冲区内容,表现为规律性的“啪嗒”声。环形缓冲的first处理回绕,n > first时从缓冲区头部再拷一段。SDL_AtomicIncRef只是计数,不会阻塞;不要在回调里printf,标准输出锁可能让音频线程卡住,听感就是间歇性卡顿。
4.3 用 SDL_QueueAudio 快速验证链路
在写回调队列之前,可以先用SDL_QueueAudio把“解码 + 重采样”链路跑通。它内部维护队列,不需要自己写回调,适合验证 PCM 数据是否正确。等声音正常了,再换成回调队列做低延迟和精细水位控制。
SDL_AudioSpec spec; SDL_zero(spec); spec.freq = 48000; spec.format = AUDIO_S16SYS; spec.channels = 2; spec.samples = 1024; spec.callback = NULL; // QueueAudio 模式不需要回调 SDL_AudioDeviceID dev = SDL_OpenAudioDevice(NULL, 0, &spec, &have, 0); SDL_PauseAudioDevice(dev, 0); // pcm_bytes 必须是 interleaved S16,且采样率/声道和 have 一致 SDL_QueueAudio(dev, pcm_bytes, pcm_len); // 控制水位:队列里别堆太多,避免暂停/seek 反应迟钝 while (SDL_GetQueuedAudioSize(dev) > (Uint32)(have.freq * have.channels * 2 * 2)) { SDL_Delay(5); }SDL_QueueAudio的第三个参数是字节数,不是样本数。队列水位按“两秒音频字节数”控制:freq * channels * 2 字节 * 2 秒。堆太多会导致暂停后声音还继续放一会儿,seek 时旧数据清不干净。SDL_GetQueuedAudioSize返回的是还没播完的字节数,调试时每 500 毫秒打印一次,能直观看到生产速度和消费速度是否匹配。
4.4 暂停、恢复与设备切换的线程边界
暂停不是简单把SDL_PauseAudioDevice(dev, 1)一调就完事。回调线程可能正在拷贝队列,解码线程可能正在入队,两个线程同时动队列必须加锁。恢复时如果队列已经空了,要先补一段静音或重新预填,否则开头会欠载。设备切换(比如耳机插拔)在 SDL2 里通常会触发设备事件,简单工程可以直接重建SDL_AudioDeviceID,但重建前必须先把旧设备暂停、关闭,再释放队列,顺序反了就是退出崩溃。
// 暂停:先停设备,再处理队列,避免回调继续消费 SDL_PauseAudioDevice(dev, 1); SDL_LockMutex(queue.lock); // 这里可以清队列或保留,看业务需求 SDL_UnlockMutex(queue.lock); // 退出:先停回调,再关设备,最后释放队列内存 SDL_PauseAudioDevice(dev, 1); SDL_CloseAudioDevice(dev); SDL_DestroyMutex(queue.lock); free(queue.buf);SDL_CloseAudioDevice返回后回调不会再被调用,这是释放队列的安全点。很多崩溃发生在“先free(queue.buf)再关设备”,回调线程刚好醒来读到野指针。设备重建时同样按这个顺序:暂停 → 关闭 → 释放旧队列 → 建新队列 → 打开新设备 → 取消暂停。
5. 排查与避坑:五个“日志正常但耳朵不对”的现场
5.1 有解码日志、声卡也打开,却完全没声音
现象:控制台打印了frame: nb_samples=1024 rate=48000 fmt=8,SDL_OpenAudioDevice返回非 0,程序不崩,但扬声器一点动静都没有。
原因通常有三类。第一类是SDL_PauseAudioDevice(dev, 0)忘了调用,SDL2 打开设备后默认是暂停状态,不取消暂停回调永远不会跑。第二类是回调里len和样本格式对不上,比如设备是双声道 S16,回调按单声道字节数算,实际只填了一半,另一半是静音,听感像“声音很小”而不是完全没声。第三类是重采样输出写到了out_data[1],但设备要的是out_data[0],planar 和 packed 弄反了。
解决:先确认SDL_PauseAudioDevice(dev, 0)在打开设备后调用;再在回调里打印一次len和have.channels * 2的倍数关系;最后用SDL_QueueAudio模式灌一段已知正弦波,如果 QueueAudio 有声音而回调没声音,问题一定在回调的队列拷贝逻辑。
5.2 开头“啪”一声,结尾“滋”一下
现象:每次播放开头有一声爆音,结尾有一阵短促的“滋啦”,中间人声正常。
原因:开头爆音多半是回调没有先memset(stream, 0, len),声卡把缓冲区里的旧数据播出来了;也可能是第一帧 PCM 从非零样本开始,或者队列里混入了未初始化内存。结尾“滋啦”通常是av_samples_get_buffer_size按out_samples算而不是按converted算,尾部多送了一段未定义数据;或者 flush 重采样器时缓冲区分配小了,swr_convert写越界。
解决:回调第一行强制清零;入队字节数严格用converted计算;flush 循环里每次重新分配缓冲区,不要复用上一次的指针;用 Audacity 或ffmpeg -f s16le把输出的 PCM 存成文件,看尾部是否有非零垃圾样本。
5.3 人声变快、音调变高
现象:本来正常的访谈录音,播放出来像快进,人声尖,音乐节奏也快。
原因:输入采样率和输出采样率写反了,或者 SDL 设备实际以 48 kHz 打开,而重采样输出按 44.1 kHz 送。人声变快通常是“输出采样率大于实际数据采样率”,声卡按 48 kHz 消费 44.1 kHz 的数据,时间被压缩。另一个常见原因是frame->sample_rate没从帧里取,而是用了容器里的par->sample_rate,两者在部分文件里不一致。
解决:打印have.freq和frame->sample_rate,确认swr_alloc_set_opts2的输出采样率等于have.freq,输入采样率等于frame->sample_rate;不要用常量 44100 或 48000 写死;如果设备允许频率变化,按have重建SwrContext。
5.4 播到一半卡死,CPU 占用飙高
现象:播放十几秒后声音停住,进程 CPU 跑到 100%,日志不再输出,像死循环。
原因:av_read_frame返回负值后没有退出循环,或者返回AVERROR(EAGAIN)时没有av_usleep直接continue,形成忙等。另一个高频原因是队列满后解码线程还在死循环入队,没有等待水位下降。回调里加锁时间过长也会让音频线程和解码线程互相等,但那种表现是卡顿而不是纯 CPU 高。
解决:av_read_frame返回<0时区分AVERROR_EOF和EAGAIN,EOF 退出,EAGAIN 睡 5 到 10 毫秒;入队前检查SDL_GetQueuedAudioSize,超过两秒水位就SDL_Delay(5);用perf top或gdb打断点看卡在哪个循环,通常是while (av_read_frame(...) >= 0)外面少了一个退出条件。
5.5 seek 后声音对不上口型
现象:拖动进度条之后,声音从错误的位置开始,或者旧声音先播完半秒才跳到新位置。
原因:只调了avformat_seek_file,没有avcodec_flush_buffers,解码器里还残留 seek 前的帧;没有清 SDL 队列,旧 PCM 还在声卡缓冲里;没有 flush 或重建SwrContext,重采样器里还有旧采样率的延迟样本。
解决:seek 的顺序是“暂停设备 →avformat_seek_file→avcodec_flush_buffers→ 清空/重建重采样器 →SDL_ClearQueuedAudio→ 恢复设备”。SDL_ClearQueuedAudio只对SDL_QueueAudio模式有效,回调队列要自己加锁清len和读写指针。seek 之后第一帧的pts要重新对齐,别拿 seek 前的时钟继续算。
6. 收尾技巧:用 1 kHz 正弦波把播放器验到“可交付”
6.1 先造一段已知采样率的测试 PCM
耳朵会骗人,示波器和已知频率的正弦波不会。交付前我习惯先生成一段 48 kHz、双声道、S16 的 1 kHz 正弦波,长度 10 秒,再用播放器播。听感上应该是平稳的“嘀——”,没有爆音、没有断续、没有音调漂移。用 FFmpeg 一条命令就能生成:
ffmpeg -f lavfi -i "sine=frequency=1000:sample_rate=48000:duration=10" \ -ac 2 -ar 48000 -f s16le test_1k_48k_stereo.pcm-f lavfi用内置信号源,sine生成正弦波,frequency=1000是 1 kHz,sample_rate=48000是采样率,duration=10是 10 秒。-ac 2转双声道,-ar 48000固定输出采样率,-f s16le输出裸 PCM,不带容器头。生成的文件可以直接喂给SDL_QueueAudio做链路验证,也可以临时给播放器加一个 raw 输入分支,跳过解封装和解码,只测重采样和 SDL 输出。
6.2 记录 underrun 和水位,不靠耳朵猜
回调里已经用SDL_AtomicIncRef记了欠载次数,再补一个水位采样:每 5 秒在主线程打印一次queue.len和underrun。如果underrun持续增长,说明解码或重采样供不上,优先查av_read_frame是否被阻塞、swr_convert是否在反复重建。如果水位长期接近 0 但underrun不涨,说明生产消费刚好打平,可以适当增大预填。
// 主线程每 5 秒采样一次,不要在回调里打印 static Uint32 last_tick = 0; Uint32 now = SDL_GetTicks(); if (now - last_tick > 5000) { SDL_LockMutex(queue.lock); size_t level = queue.len; SDL_UnlockMutex(queue.lock); fprintf(stderr, "queue=%zu bytes underrun=%d\n", level, SDL_AtomicGet(&queue.underrun)); last_tick = now; }queue.len是当前可读字节数,按have.freq * have.channels * 2换算成毫秒更直观。underrun是累计欠载次数,播放 10 秒 1 kHz 正弦波应该接近 0;如果几十次,先查预填是否不足,再查回调len是否被错误截断。这个黑匣子比反复听“好像有点卡”有用得多。
6.3 交付前跑一遍 30 秒长音频
短音频能过不代表长音频能过。最后一步用一段 30 秒、采样率 44.1 kHz、AAC 编码的音频跑完整链路,观察三件事:内存是否持续上涨、underrun是否稳定、seek 三次后声音是否还能对上。内存上涨通常是AVPacket或AVFrame没unref;underrun增长通常是队列太小或解码线程被日志拖慢;seek 错位按 5.5 的顺序重排一遍。
从那以后我每次接音频播放,都先把have.freq / have.format / have.channels打出来,再用 1 kHz 正弦波走一遍队列水位,确认不是靠耳朵猜。这套 ffmpeg SDL2 PCM Player 的源码和测试脚本我放在同一个包里,拿到后先跑正弦波那条链路,再换真实音频,能省掉很多“听起来不对但不知道哪错”的时间。希望帮到你。
本文还有配套的精品资源,点击获取