☰
FFmpeg+SDL2音频播放链路:PCM重采样与环形缓冲实战
2026/10/26 14:51:00 网站建设 项目流程

简介:一份基于FFMPEG与SDL2实现的音频播放器工程,面向具备C/C++基础、希望学习音视频解码与音频渲染的开发者,重点解决MP3、AAC等音频文件解码后通过SDL2输出播放的问题,并采用链表队列在解码与播放线程之间传递数据。包内共231个文件,以151个C/C++头文件、10个lib静态库、8个dll动态库为核心,配有工程解决方案、项目配置文件和少量mp3、aac测试音频及批处理脚本,压缩包约15.45MB,便于直接对照编译运行与调试。已有1124人学习,适合作为音视频入门的实践样例。读者可从中了解FFmpeg解码流程、SDL2音频回调与队列缓冲的配合方式,掌握线程解耦和缓冲管理的基本思路,并借助现成工程快速验证环境配置与依赖链接,减少从零搭建播放器时的踩坑成本。

1. 从「进度条在动,音箱没声」那次排查说起

有个朋友把一段音频播放的 Demo 拷进自己的小工具里,跑起来日志打得热闹,帧数在涨、时间戳在跳,就是扬声器一点动静没有。翻到最后发现是 SDL2 那边打开的是AUDIO_S16SYS,而解码出来直接塞进去的是 FFmpeg 的 planar float,格式对不上,声音全变成了空气。这类问题几乎是每个手写 FFmpeg + SDL2 音频播放链路的人都要交的学费:解码和出声是两个世界,中间隔着一层必须自己搭的桥。

这份资源做的就是这座桥——拿 FFmpeg 负责解封装和解码,把任意格式的音频统一成一段规整的 PCM,再交给 SDL2 的音频回调按需取走。适合两类人:一是想搞明白「音频播放到底要经过哪几道工序」的初学者,二是要在自己项目里塞一个轻量播放内核、不想拖进整个播放器框架的开发者。下面按我拆解的顺序,从目录结构一路讲到能改的地方。

2. 项目骨架与技术选型:FFmpeg 管解码,SDL2 管出声

先把这个项目的分工说清楚,不然后面读代码容易迷路。FFmpeg 这一侧承担的是「把文件变成字节流」:libavformat拆容器、找到音频流,libavcodec把压缩包解成AVFrame,libswresample把千奇百怪的采样格式、采样率、声道布局统一成播放设备能吃的样子。SDL2 这一侧只干一件事:开一个音频设备,注册一个回调,操作系统缺数据的时候来你这儿要,你从缓冲区舀一勺给它。两边通过一块环形缓冲区解耦,谁也不等谁。

这套分工的好处是边界极清晰。解码慢了大不了缓冲见底,出声那边永远不会阻塞在av_read_frame上;播放设备换了采样率,改的也只是重采样那一层的目标参数。坏处是中间的缓冲和状态得自己管,这也是后面所有坑的来源。

2.1 目录结构:六个源文件划清四层职责

我拿到的这份工程不大,但分层是干净的,基本按「一层一个 .c」来切:

pcm-player/ ├── CMakeLists.txt ├── include/ │ ├── player.h # 对外只暴露 open/play/pause/seek/close │ ├── ring_buffer.h # 无锁读、加锁写的字节环形队列 │ └── log.h ├── src/ │ ├── main.c # 命令行入口,参数解析 + 主循环 │ ├── demuxer.c # avformat:打开输入、找音频流、读 packet │ ├── decoder.c # avcodec:send_packet / receive_frame │ ├── resampler.c # swresample:统一成 S16 / 立体声 / 44.1kHz │ ├── audio_out.c # SDL2:打开设备、注册回调、启停 │ └── ring_buffer.c └── assets/ └── sample.mp3

player.h是全项目唯一对外的门面,main.c里看不到任何AVFormatContext或者SDL_AudioDeviceID,这样后面想把这套东西塞进 GUI 或者服务端,替换main.c就行。demuxer.c和decoder.c拆开看着有点碎,但音频播放里 seek 的时候只需要 flush 解码器、不需要重开容器,这个拆分就体现价值了。

依赖只有三个:libavformat、libavcodec、libavutil,加上libswresample和 SDL2。没有libavfilter,因为音量、淡入淡出这种活儿在这个体量下自己乘一下比挂滤镜图便宜得多。

2.2 为什么音频必须过一次重采样

很多人第一次写会想:解码出来的AVFrame里不就是 PCM 吗,直接塞给 SDL 不完了?不行,原因有三层。

第一层是采样格式。MP3 解出来通常是AV_SAMPLE_FMT_S16P(planar,左右声道分开存两块内存),AAC 常见AV_SAMPLE_FMT_FLTP(32 位浮点 planar),而 SDL 最简单好用的输出格式是AUDIO_S16SYS(packed,左右声道交错)。planar 和 packed 的内存布局完全不同,直接把frame->data[0]丢过去,播出来要么只有左声道,要么全是噪音。

第二层是采样率。解码器输出可能是 48000,而你打开设备时想要 44100,不统一就是变速变调。

第三层是声道数。源可能是单声道、5.1,设备只认立体声,不做下混或者上混,SDL 那边行为未定义。

SwrContext就是干这个的,一次配置,后面每帧swr_convert一调,输出长度用swr_get_out_samples估。它的配置代码不长,但参数顺序特别容易写反,下面这段是核心:

/* resampler.c —— 初始化重采样上下文 */ int resampler_init(Resampler *rs, AVCodecContext *dec) { /* 目标格式:SDL 最省心的 S16 交错、双声道、44100 */ AVChannelLayout out_ch = AV_CHANNEL_LAYOUT_STEREO; /* 注意参数顺序: (输出上下文, 输出声道布局, 输出采样格式, 输出采样率, 输入声道布局, 输入采样格式, 输入采样率, 日志参数, 选项) */ int ret = swr_alloc_set_opts2(&rs->ctx, &out_ch, /* out ch_layout */ AV_SAMPLE_FMT_S16, /* out fmt */ 44100, /* out rate */ &dec->ch_layout, /* in ch_layout */ dec->sample_fmt, /* in fmt */ dec->sample_rate, /* in rate */ 0, NULL); if (ret < 0) return ret; if ((ret = swr_init(rs->ctx)) < 0) { /* 不 init 等于没配 */ swr_free(&rs->ctx); return ret; } rs->out_rate = 44100; return 0; }

swr_alloc_set_opts2有九个参数,前四个描述「你要什么」,中间三个描述「你给它什么」,写反了不会编译报错,只会静悄悄地播出一堆杂音。swr_init不能省,swr_convert之前必须调用一次。

对 FFmpeg 4.x 及更早的版本,这套结构体还没有ch_layout,得用dec->channel_layout加dec->channels,函数也得换成swr_alloc_set_opts。我在工程里加了版本宏做兼容:

#if LIBAVUTIL_VERSION_MAJOR >= 57 /* FFmpeg 5.1+ 使用 AVChannelLayout */ const AVChannelLayout *in_ch = &dec->ch_layout; #else AVChannelLayout legacy; av_channel_layout_default(&legacy, dec->channels); const AVChannelLayout *in_ch = &legacy; #endif

这个宏判断的依据是libavutil的 major 版本号跳到 57 的那一版,工程里其他地方凡是碰到channels、channel_layout的地方都用同一个宏包着,免得一半新一半旧编译不过。

2.3 构建:pkg-config 比手写 -l 靠谱

CMakeLists 写得挺朴素,依赖全靠pkg-config穿针引线:

cmake_minimum_required(VERSION 3.16) project(pcm_player C) set(CMAKE_C_STANDARD 11) find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswresample) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) add_executable(pcm_player src/main.c src/demuxer.c src/decoder.c src/resampler.c src/audio_out.c src/ring_buffer.c) target_include_directories(pcm_player PRIVATE include) target_link_libraries(pcm_player PRIVATE PkgConfig::FFMPEG PkgConfig::SDL2 m)

IMPORTED_TARGET这个写法值得养成习惯,它会自动把-I、-L、-l和--cflags里的宏定义都带上,不用自己抄一长串链接参数。构建就是三步:

mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j$(nproc) ./pcm_player ../assets/sample.mp3

跑之前用pkg-config --modversion libavcodec确认一下版本,低于 58 的老发行版仓库要自己补新源,否则ch_layout那套编译不过。SDL2 在部分环境下需要额外传SDL_CFLAGS,遇到Could NOT find SDL2优先查是不是只装了运行时不装开发包。

提示:Debug 构建时加上-DCMAKE_C_FLAGS="-fsanitize=address",音频回调里那些越界读写用肉眼是看不出来的,只有 ASan 能抓住。

3. 核心链路拆解:一个音频包从 demux 到扬声器的完整旅程

这一章是全项目的重头戏。一个音频文件从进内存到变成声音,中间要经过打开、找流、读包、解码、重采样、入缓冲、回调取数七个动作,我按代码实际执行顺序逐个展开,每段都能直接抄进自己的工程。

3.1 打开输入、定位音频流、拉起解码器

初始化这一段有个固定套路,但每一行都可能是失败点:

/* demuxer.c —— 打开容器并准备音频解码器 */ int demuxer_open(Demuxer *dm, const char *path, AVCodecContext **out_dec) { int ret; /* 1. 打开输入,第三个参数留 NULL 让 FFmpeg 自己探测格式 */ if ((ret = avformat_open_input(&dm->fmt, path, NULL, NULL)) < 0) return ret; /* 2. 读一段数据补全流信息,没有这步 duration/sample_rate 常常是 0 */ if ((ret = avformat_find_stream_info(dm->fmt, NULL)) < 0) goto fail_fmt; /* 3. 找「最佳」音频流,它会按解码器可用性、码率、语言做排序 */ const AVCodec *codec = NULL; ret = av_find_best_stream(dm->fmt, AVMEDIA_TYPE_AUDIO, -1, -1, &codec, 0); if (ret < 0) goto fail_fmt; /* 纯视频容器会走到这里 */ dm->stream_index = ret; dm->stream = dm->fmt->streams[ret]; /* 4. 从流的 codecpar 复制参数,再打开解码器 */ AVCodecContext *dec = avcodec_alloc_context3(codec); if (!dec) { ret = AVERROR(ENOMEM); goto fail_fmt; } if ((ret = avcodec_parameters_to_context(dec, dm->stream->codecpar)) < 0) goto fail_dec; if ((ret = avcodec_open2(dec, codec, NULL)) < 0) goto fail_dec; *out_dec = dec; return 0; fail_dec: avcodec_free_context(&dec); fail_fmt: avformat_close_input(&dm->fmt); return ret; }

avformat_find_stream_info这一步在处理网络流或者无索引容器时会真的读一小段数据,耗时能到几百毫秒,别把它放到 UI 线程里。av_find_best_stream返回的ret是流索引,不是 0/1 的布尔值,很多示例代码里写成if (av_find_best_stream(...)),索引是 0 的音频流就被当成失败了,这是个很隐蔽的翻车点。

avcodec_parameters_to_context把codecpar里的sample_rate、ch_layout、sample_fmt拷进AVCodecContext,后面重采样的输入参数就是从这里读的。如果跳过这一步直接avcodec_open2,解码器拿到的是一堆零参数,开不起来的概率极高。

3.2 av_read_frame 到 send_packet / receive_frame

FFmpeg 新版的解码 API 是「送一个包、收若干帧」的推拉模型,和早年的avcodec_decode_audio4完全不是一回事。这套模型最大的好处是能正确处理「一个包解出多帧」和「一个帧要攒好几个包」这两种情况,音频里 B 帧少,但 AAC 的后向兼容也存在,按老写法会丢数据。

/* decoder.c —— 送包与收帧的标准循环 */ int decoder_run(Player *p) { AVPacket *pkt = av_packet_alloc(); AVFrame *frm = av_frame_alloc(); while (!p->abort) { int ret = av_read_frame(p->dm.fmt, pkt); if (ret == AVERROR_EOF) { /* 文件读完,送一个空包通知解码器 flush */ avcodec_send_packet(p->dec, NULL); } else if (ret < 0) { break; /* 真错误 */ } else if (pkt->stream_index != p->dm.stream_index) { av_packet_unref(pkt); /* 不是音频流,直接扔掉 */ continue; } else { ret = avcodec_send_packet(p->dec, pkt); av_packet_unref(pkt); /* 送完立刻释放,解码器内部会拷贝 */ if (ret == AVERROR(EAGAIN)) { /* 解码器还满着,先收帧,稍后重送这个包 */ continue; } } /* 无论送包成功与否,都把解码器里能拿的帧全部抽干 */ while ((ret = avcodec_receive_frame(p->dec, frm)) == 0) { player_push_frame(p, frm); /* 送重采样 → 入缓冲 */ av_frame_unref(frm); } if (ret != AVERROR(EAGAIN) && ret != AVERROR_EOF && ret < 0) break; } av_packet_free(&pkt); av_frame_free(&frm); return 0; }

这段代码里有三个必须要守的规矩。

av_read_frame之后不管拿没拿到音频流,av_packet_unref都不能漏。它内部会av_buffer_unref掉包的引用计数,漏一次在长时间播放里就是内存一路涨。上面的写法把 unref 放在送包之后立刻执行,是因为avcodec_send_packet会自己 ref 一份,不需要你留着。

EAGAIN是正常返回,不是错误。它表示解码器内部缓冲满了,这时候不要去睡一会儿,应该转到avcodec_receive_frame把帧抽干,抽完再回来送同一个包。但上面的简化版有个小瑕疵:continue回到循环顶部会重新av_read_frame,把刚读的包丢了。稳妥的写法是把未送出的包暂存,下一次循环先尝试送它,工程里的完整版用了一个pending_pkt标志位处理。

收帧循环必须把它抽干,avcodec_receive_frame返回EAGAIN才停。很多 Demo 只收一帧就走,遇到一个包出多帧的格式(比如某些 FLAC 或长帧 AAC)就会丢掉一部分数据,听起来像轻微的咔哒声,很难定位。

解码出来的AVFrame里带有best_effort_timestamp或者pts,单位是AVStream::time_base,要换算成秒得乘av_q2d(stream->time_base)。播放进度、seek 定位、缓冲水位计算都依赖这个值,别用一帧累加的做法,采样率一不整除误差就攒起来了。

3.3 重采样:从 AVFrame 到规整 PCM

有了AVFrame,下一站是SwrContext。这一段最关键的是输出缓冲区要自己算大小,swr_convert不会帮你分配。

/* resampler.c —— 单帧转换 */ int resampler_convert(Resampler *rs, AVFrame *in, uint8_t **out_buf, int *out_samples) { /* 最坏情况下的输出样本数,1 是留给重采样器内部缓存的余量 */ int max_out = swr_get_out_samples(rs->ctx, in->nb_samples); int need_bytes = max_out * 2 /*ch*/ * 2 /*S16=2字节*/; if (rs->cap < need_bytes) { av_freep(&rs->buf); rs->buf = av_malloc(need_bytes); if (!rs->buf) return AVERROR(ENOMEM); rs->cap = need_bytes; } uint8_t *dst[1] = { rs->buf }; int n = swr_convert(rs->ctx, dst, max_out, (const uint8_t **)in->extended_data, in->nb_samples); if (n < 0) return n; *out_buf = rs->buf; *out_samples = n; return 0; }

swr_get_out_samples是按采样率比例加滤波余量估算的上界,直接用in->nb_samples * out_rate / in_rate会导致偶发溢出——重采样器内部有 16 到 32 个样本的多相滤波延迟,实际输出会略多于这个值。缓冲区复用一个rs->buf就够了,每帧av_malloc一次在 44.1kHz 下相当于每秒几十次分配,纯属给分配器添堵。

extended_data和data的区别在于声道数超过 8 之后,data只有 8 个指针位,多的部分在extended_data里。统一用extended_data更安全。

转换完的字节数不等于样本数,n是每声道的样本数,实际字节数是n * 输出声道数 * 每样本字节数。这一处算错,塞进 SDL 的数据长度就错了,症状是每个缓冲区周期末尾有一下轻微的爆音,或者干脆只播了一部分。

3.4 环形缓冲与 SDL 回调:谁也别等谁

到了 SDL2 这一侧,结构是「主线程不停往里写,音频回调不停往外读」。中间用一块环形缓冲区隔开,写端受SDL_LockAudioDevice保护,读端在回调里不需要加锁(因为 SDL 保证同一时刻只有一个回调在跑)。

/* audio_out.c —— 音频回调,只做拷贝,绝不做解码或分配 */ static void sdl_audio_callback(void *userdata, Uint8 *stream, int len) { Player *p = (Player *)userdata; int filled = 0; while (filled < len) { int got = ring_buffer_read(&p->rb, stream + filled, len - filled); if (got <= 0) break; filled += got; } /* 缓冲不够的部分必须补静音,否则放的是上一次的残留内存 */ if (filled < len) { SDL_memset(stream + filled, 0, len - filled); p->underrun_count++; } } /* 打开设备:wanted 是「我想要」,obtained 是「实际给我」 */ int audio_out_open(Player *p, SDL_AudioCallback cb) { SDL_AudioSpec wanted, obtained; SDL_zero(wanted); wanted.freq = 44100; wanted.format = AUDIO_S16SYS; /* 与重采样输出必须一致 */ wanted.channels = 2; wanted.samples = 1024; /* 回调粒度,约 23ms */ wanted.callback = cb; wanted.userdata = p; p->dev = SDL_OpenAudioDevice(NULL, 0, &wanted, &obtained, SDL_AUDIO_ALLOW_FREQUENCY_CHANGE); if (p->dev == 0) return -1; /* 关键:以 obtained 为准,不要拿 wanted 去算回调长度 */ p->out_rate = obtained.freq; p->out_channels = obtained.channels; SDL_PauseAudioDevice(p->dev, 0); /* 0 = 开始播放 */ return 0; }

回调里那个补静音的SDL_memset是最容易被忽略的一行。缓冲区欠载时stream里的内容是未定义的,不补静音就是刺啦刺啦的杂音,而且只在 CPU 忙的时候出现。

SDL_AUDIO_ALLOW_FREQUENCY_CHANGE这个标志让 SDL 有权限把 44100 改成 48000 之类的硬件原生采样率。开了就得认obtained.freq,环形缓冲区的水位计算、进度显示全都得用实测值,用wanted.freq算出来的时间会偏。如果不想要这个行为,传 0,SDL 会在打开失败时返回 0,那就得自己降级重试。

环形缓冲区大小是另一个要调的量。太小(低于两个回调周期)就频繁欠载,太大(几秒)则 seek 之后明显滞后。我一般按out_rate * 2ch * 2byte * 0.5s给个 500ms 的容量,用的时候留 200ms 左右的余量做水位控制。

4. 播放控制与状态机:暂停、seek、音量、播放结束

能出声只是及格线,真正决定这套东西能不能进项目的,是暂停、拖动、音量这些交互。这一块最容易写乱,因为没有统一的「正确姿势」,全靠自己把状态管清楚。

4.1 状态机与线程模型

工程里用了三个状态和一个标志位:IDLE、PLAYING、PAUSED,外加abort用于退出。解码跑在独立线程里,主线程只负责接收命令和转状态。

/* player.h —— 对外状态机 */ typedef enum { ST_IDLE, ST_PLAYING, ST_PAUSED, ST_EOF } PlayerState; typedef struct Player { Demuxer dm; AVCodecContext *dec; Resampler rs; RingBuffer rb; SDL_AudioDeviceID dev; PlayerState state; atomic_int abort; /* 主线程置 1,解码线程退出 */ atomic_llong seek_req; /* -1 表示无请求,非负数是以 AV_TIME_BASE 计的微秒 */ double clock; /* 当前播放位置,供 UI 读取 */ int64_t duration_us; } Player;

用原子变量传命令而不是加锁,是因为解码线程大半时间阻塞在av_read_frame上,锁竞争会让暂停响应变慢。主线程想 seek,只需要把目标时间写进seek_req,解码线程在每轮循环开头检查一次。

暂停的实现有个小技巧:不用自己维护「暂停中」的阻塞逻辑,直接SDL_PauseAudioDevice(dev, 1)让 SDL 停掉回调,解码线程照常往环形缓冲区里写,写满就阻塞在写端。恢复的时候SDL_PauseAudioDevice(dev, 0),声音立刻接上,没有延迟。

4.2 seek 的正确姿势:flush 三件套

seek 是音频播放里最容易出问题的地方。做对了是毫秒级响应,做错了就是长达半秒的旧内容残留、时间戳错位、或者直接卡死。完整的动作是三步:

/* player.c —— 执行一次 seek */ static void do_seek(Player *p, int64_t target_us) { /* 1. 换算成流自己的时间基,用 BACKWARD 保证落在关键帧之前 */ int64_t ts = av_rescale_q(target_us, AV_TIME_BASE_Q, p->dm.stream->time_base); av_seek_frame(p->dm.fmt, p->dm.stream_index, ts, AVSEEK_FLAG_BACKWARD); /* 2. 清空解码器内部缓存的帧,不清会把 seek 前的残响放出来 */ avcodec_flush_buffers(p->dec); /* 3. 清空环形缓冲,并重置重采样器(它内部也有滤波残留) */ SDL_LockAudioDevice(p->dev); ring_buffer_clear(&p->rb); SDL_UnlockAudioDevice(p->dev); swr_close(p->rs.ctx); swr_init(p->rs.ctx); }

av_seek_frame的第四个参数用AVSEEK_FLAG_BACKWARD而不是AVSEEK_FLAG_ANY。音频流的关键帧间隔通常很小,BACKWARD 定位到最近的关键帧之后继续解码到目标位置,精度损失基本感觉不到;用 ANY 虽然更精确,但部分容器会落到帧中间,解出来的第一帧是坏的。

avcodec_flush_buffers清的是解码器内部队列,不清的话 seek 之后会先放出旧位置的几帧,听起来像「拖回去又弹回来一点」。这一步很多人只在改采样率的时候记得做。

swr_close加swr_init是为了清掉重采样器内部的多相滤波器缓存。重采样器在两次swr_convert之间会保存一部分历史样本用于插值,seek 前后这部分样本属于两段不相干的音频,不重置的话接缝处会有一声「噗」。

ring_buffer_clear必须在SDL_LockAudioDevice保护下做,否则可能正好在回调读取的中途把读写指针清零,指针错位之后再怎么读都是乱码。

4.3 音量:别上滤镜,自己乘

这个工程没引libavfilter,音量就是一个float系数,在重采样之后、入环形缓冲之前乘上去:

/* 对 S16 交错数据做定点音量,比浮点快很多 */ static void apply_volume(int16_t *samples, int count, float vol) { if (vol >= 0.999f) return; /* 满音量直接跳过 */ int32_t q = (int32_t)(vol * 256.0f); /* Q8 定点 */ for (int i = 0; i < count; i++) { int32_t v = (samples[i] * q) >> 8; /* 饱和处理,防止溢出回绕成反相噪音 */ if (v > 32767) v = 32767; if (v < -32768) v = -32768; samples[i] = (int16_t)v; } }

count是样本总数(不是帧数),等于out_samples * channels。这里的饱和处理不能省,乘以系数之后超过 int16 范围直接截断,波形从正峰翻到负峰,听感上就是破音。Q8 定点比浮点乘法快三倍左右,在 44.1kHz 立体声下每秒 17 万次运算,差异不大,但在嵌入式设备上能明显降 CPU。

淡入淡出可以在同一个位置做,把系数按样本数线性过渡即可。切歌、暂停恢复的时候加 20ms 淡入,能消除掉大部分「啪」的爆音——那些爆音本质上是波形从非零值突然跳到零。

5. 踩坑排查:音频播放最容易翻车的五个地方

这一章是我按自己踩过的和帮别人定位过的顺序排的,症状从「完全没声」到「偶尔卡一下」分布,基本覆盖了新手最常见的几类。

5.1 完全出不了声:从设备到格式的排查顺序

现象:程序正常跑完,日志显示解码了多少帧、拿到了多少样本,扬声器全程静音。

原因:SDL_OpenAudioDevice之后忘了SDL_PauseAudioDevice(dev, 0)。SDL 打开的设备默认是暂停状态,这是设计如此,防止你没准备好数据就开始播。这个坑我第一次遇到的时候查了半小时解码器,最后发现是一行没写。

解决:按「设备有没有打开成功 → 有没有 unpause → 回调有没有被调用 → 回调里读出来的字节数是不是 0」的顺序排查。回调被调用了但ring_buffer_read一直返回 0,说明写端根本没在写,问题回到解码链路上——在player_push_frame里打一行样本数日志,很快就能定位。

另一种静音的成因是格式对不上。SDL 要AUDIO_S16SYS,你喂的是 planar float,两声道的数据被当成单声道交错来解释,音量会低到听不见,同时伴随明显失真。看obtained.format和你重采样输出格式是不是同一个,是最快的确认方式。

5.2 声音不对:planar/packed 混淆与采样率错配

现象:有声音,但像是被加速或者降调了;或者左耳有右耳没有;或者背景有持续的沙沙声。

原因:三种不同的错法。变速变调几乎一定是采样率没对齐,比如解码器输出 48000 你按 44100 传给 SDL,播放速度会快 8.8%。单边声道或者音量偏低是 planar/packed 混淆,等于把两块独立内存当成交错数据读。持续沙沙声则是缓冲区尾部没补静音,把未初始化的内存放出来了。

解决:在重采样初始化之后加一行日志,把输入输出的sample_fmt、sample_rate、ch_layout全打出来,两边对照。SDL 那边也打一次obtained的实际值。这三个数对上了,剩下的问题基本都在缓冲区和时序上。

需要特别注意的是 FFmpeg 的AV_SAMPLE_FMT_S16(packed)和AV_SAMPLE_FMT_S16P(planar)在枚举值上只差一个 P,肉眼扫代码时极易看漏。我现在的习惯是重采样目标格式写成常量宏,全项目只有一处定义,改的时候不会漏。

5.3 播放中途断掉或卡顿:EAGAIN 与缓冲水位

现象:播十几秒突然停住不动,日志也不再更新;或者偶发「咔」一声,尤其在系统负载高的时候。

原因:停住大概率是avcodec_send_packet返回EAGAIN之后被当成错误break了循环。这个返回值的意思是「我这儿满了,你先把我解出来的帧收走」,属于正常流程控制,不是失败。偶发咔哒声则是环形缓冲区欠载,回调取不到足够数据,只能补静音。

解决:把所有ret < 0的地方逐个过一遍,确认AVERROR(EAGAIN)和AVERROR_EOF都被单独处理了。欠载的问题看underrun_count这个计数器,如果它在涨,就加大环形缓冲容量,或者在写端加一个「水位低于 50% 就暂停解码线程继续读、先让 CPU 喘口气」的简单节流。

还有一种卡顿来自wanted.samples设得太小。1024 个样本在 44.1kHz 下大约是 23ms,回调频率每秒 43 次左右,这是比较合适的值;设成 128 的话回调频率飙到 340 次,每次处理的数据又少,调度开销本身就够呛。

5.4 退出时崩溃:释放顺序与线程收回

现象:播放正常,按 Ctrl+C 或者关窗口的时候 SIGSEGV,ASan 报heap-use-after-free。

原因:先avformat_close_input或者avcodec_free_context,后停解码线程。解码线程还在av_read_frame里,用的AVFormatContext已经被释放了。

解决:释放顺序严格反着初始化来,并且必须先让线程退出。正确顺序是置abort标志 →SDL_PauseAudioDevice暂停 → join 解码线程 → 关音频设备 →swr_free→avcodec_free_context→avformat_close_input。中间任何一步颠倒都可能崩。

另外SDL_Quit之后不要再调用任何 SDL 函数,包括SDL_CloseAudioDevice,这个顺序反了也是常见的崩溃点。工程里在main的末尾统一收拾,避免多处调用。

5.5 API 版本漂移:老教程抄过来编译不过

现象:照着几年前的帖子写,avcodec_decode_audio4找不到,codec->channels报 deprecated,改了几处之后出来的声音还是不对。

原因:FFmpeg 在 4.x 到 6.x 之间做了几轮破坏性变更。解码 API 从一次调用返回一帧,改成了 send/receive 推拉模型;声道信息从channels+channel_layout两个字段合并成AVChannelLayout结构;av_init_packet在 6.0 之后被标记移除。

解决:把 FFmpeg 版本当成项目依赖的一部分写进 README,pkg-config --modversion libavcodec的结果记录下来。跨版本兼容用LIBAVUTIL_VERSION_MAJOR和LIBAVCODEC_VERSION_MAJOR的宏判断在源码里做条件编译,比在文档里写「请使用某版本」靠谱。

注意:av_init_packet可以安全地不用,av_packet_alloc会在内部把字段全部初始化;用了反而在 6.0 之后直接编译报错。

6. 二次开发:把它变成能装进自己项目的播放内核

前面五章讲的是一条能跑的链路,但要真塞进自己的工程里,还得在接口层面动一刀。这份工程的player.h已经是个不错的起点,我在它基础上做的改造主要是三件事。

第一件是把回调式推进改成「外部驱动」。现在的player_run是个死循环,占住一个线程不放;如果宿主是 GUI 或者游戏引擎,更适合暴露一个player_tick(Player*, int max_ms),每次只干一小段活,由外部的事件循环按帧调用。这样播放器不占额外线程,暂停和退出都跟着主循环走,状态同步简单很多。

/* 外部驱动版本:每次调用最多处理 max_ms 毫秒的数据 */ int player_tick(Player *p, int max_ms) { int64_t deadline = av_gettime_relative() + max_ms * 1000; while (av_gettime_relative() < deadline) { if (ring_buffer_free_space(&p->rb) < p->rb.cap / 4) break; /* 缓冲够满了,本次让出 */ if (decode_one_frame(p) != 0) /* 单帧推进,内部已封装 send/receive */ break; } return 0; }

第二件是把输出格式做成可协商的。现在目标格式写死成 S16/立体声/44100,实际项目里可能要输出浮点给混音器,或者要保持源采样率做无损处理。把Resampler的目标参数抽成结构体,在player_open时传入,内部按需决定要不要真的调swr_alloc(源格式和目标一致时可以直接跳过重采样,省一次内存拷贝)。

改造点现状建议做法影响
驱动方式独占解码线程暴露tick,外部驱动便于嵌入 GUI / 引擎
输出格式硬编码 S16/44.1k/2ch参数结构体协商兼容浮点混音链路
环形缓冲定长 malloc按 500ms 容量动态算换采样率不用改代码
错误上报返回负数统一错误回调 + 错误码宿主可做重试/提示
元数据未解析读AVDictionary的 title/artist播放列表需要

第三件是补上解码侧的真实需求:无缝切歌。现在的实现播到 EOF 就停,切下一首要重建整条链路,中间有几百毫秒空档。做法是把demuxer和decoder的生命周期和Player解耦,EOF 时先不销毁,只avcodec_flush_buffers然后换AVFormatContext,环形缓冲区里的残留数据继续播完,实现交叉淡化。

改完记得用一份多格式样本集回归一遍:MP3、AAC、FLAC、单声道 WAV、5.1 声道文件各来一个。我吃过一次亏,只测了 MP3,上线之后遇到 FLAC 的 24bit 输出,重采样目标还是 S16,swr_convert直接返回负数,整条链路静默失败。从那以后我每次改重采样参数,都强制走一遍这份样本集,一个都不跳。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询