资讯动态

FFmpeg+SDL2音频播放实战:解码分工、重采样与避坑指南

发布时间:2026/10/17 13:21:23 来源:尧图企业网站定制
简介基于FFmpeg与SDL2编写的一套音频播放器完整工程面向具备C/C基础、希望深入理解音视频解码与播放流程的开发者。程序以MP3文件为输入借助FFmpeg完成解封装、解码与重采样将产生的PCM数据放入链表队列SDL2音频设备再从队列中读取并播放多线程协作的架构清晰展示了音频播放的完整数据链路工程参考价值突出。资源包共231个文件压缩后约15.45MB包含151个头文件、C源文件、Visual Studio工程配置、批处理编译脚本、FFmpeg及SDL2动态库以及mp3、aac测试音频素材目录结构清晰源代码与依赖分离便于编译调试和二次移植。目前已有1120人学习/下载通过这套工程可以重点研习FFmpeg解码、SDL2回调播放、链表队列缓冲等关键技术点既适合初学者建立播放器开发的整体认知也能为后续扩展音频功能提供直接参考。1. FFmpegSDL2播放音频先想清楚解码和出声的分工在桌面端做音视频播放工具时基于FFmpegSDL2播放音频是很多人绕不开的第一道坎FFmpeg负责把MP3、AAC、FLAC这些压缩格式解码成PCM裸数据SDL2负责把PCM送进声卡完成出声两者之间用一个FIFO队列做缓冲。市面上不少教程教你用SDL_LoadWAV一步到位但那个接口只认未压缩的WAV遇到MP4里的AAC音轨直接翻车。我一开始也踩过这个坑后来才把整条链路理清楚。下面从SDL_AudioSpec参数配置讲起到swr_convert重采样再到多线程播放的避坑清单给新手一套能跑起来的C语言框架也给熟手一份可以直接对照的参数经验。2. 搭建音频播放管线解码线程与SDL2音频设备的职责边界2.1 解码与出声的矛盾PCM裸数据是唯一的交接点FFmpeg解码出来的AVFrame里装的是PCM数据采样格式可能是浮点、可能是平面布局SDL2音频设备需要的也是PCM但格式通常约定为有符号16位整型交错布局。两边都叫PCM字节排列却经常对不上这就是播放音频时最常见的翻车源头。想明白这一点之后整条管线的分工就很清楚了。解码线程只做一件事av_read_frame拿包avcodec_send_packet和avcodec_receive_frame解出AVFrame然后把重采样好的PCM塞进一个队列。SDL2音频设备在后台有自己的回调线程到时间了就从队列里取一段PCM交给声卡。解码线程和回调线程之间不直接通信只通过队列交换数据。这样即使某一次解码慢了几毫秒队列里还有余量声音不会立刻卡一下。常见的错误做法是在SDL2回调里直接做解码因为回调里不允许阻塞操作而avcodec_receive_frame可能因为没数据返回EAGAIN也可能因为buffer没对齐产生噪音。回调应该是纯消费者只做memcpy和音量缩放这类轻量操作。2.2 SDL_AudioSpec必须手工敲定的四个参数采样率、声道数、样本格式、缓冲长度SDL2打开音频设备靠SDL_AudioSpec结构体描述期望格式四个参数缺一不可。采样率freq一般取解码器的sample_rate常见44100或48000声道数channels取解码器的ch_layout.nb_channels样本格式format选AUDIO_S16SYS这是系统字节序的有符号16位samples是每次回调的样本数直接影响延迟和卡顿。SDL_AudioSpec want; SDL_zero(want); want.freq 44100; // 目标采样率后续重采样按它来 want.format AUDIO_S16SYS; // 有符号16位系统字节序 want.channels 2; // 双声道 want.samples 1024; // 每次回调的样本数约23ms want.callback audio_callback; want.userdata player;这段代码是SDL2播放的骨架。SDL_zero是SDL2的宏把结构体内存清零避免残留垃圾值。freq这里先写44100实际工程里应当从解码器上下文拿到真实值比如AAC经常是44100MP3可能是48000用固定值会导致重采样后播放速度异常。format用AUDIO_S16SYS是业界最通用的选择SDL2内部对它的支持也最完善。samples这个参数值得单独说。它代表SDL2每次回调请求的样本个数不是字节数。1024在44100Hz下约23毫秒这是延迟和流畅度的平衡点。设成256延迟很低但回调频繁主线程稍微卡顿就会爆音设成8192延迟近200毫秒看视频时口型根本对不上。没有绝对正确值只有一个可接受的区间后面第4章还会展开讲。2.3 从AVPacket到SDL2出声的完整数据流谁在什么时候干什么完整数据流可以写成一张思路图读取容器里的AVPacket送入解码器得到AVFrame把AVFrame里的PCM按目标格式重采样写入共享FIFO队列SDL2回调线程从队列取PCM填给声卡。每一步的数据格式都在变化所以每一步都需要明确“输入是什么、输出是什么”。typedef struct AudioPlayer { SDL_AudioDeviceID dev; // SDL2音频设备ID SDL_Thread *decode_thread; // 解码线程句柄 SDL_mutex *lock; // 队列锁 SDL_cond *cond; // 条件变量唤醒回调 uint8_t *queue_head; // 队列读指针 size_t queue_len; // 队列剩余字节数 volatile int quit; // 退出标记 } AudioPlayer;这个结构体是播放器的核心状态。queue_head和queue_len表示一个简单的字节队列实际工程会用环形缓冲区避免频繁拷贝。quit用volatile标记配合SDL_AtomicSet更规范这里为了可读性先简化。decode_thread负责往队列写SDL2音频回调负责从队列读两者通过lock和cond同步。数据流的关键约束是队列不能无限长。如果解码速度远快于播放内存会被撑爆如果解码速度跟不上队列空了回调只能填静音。所以实践上会设置队列上限比如1秒的PCM数据量到达上限就暂停解码等回调消费一部分再继续。3. 跑通第一段音频从工程初始化到PCM入队的完整流程3.1 编译环境怎么准备头文件、库文件与链接顺序FFmpeg和SDL2的开发包装好之后编译时的链接顺序会直接影响能不能编过。FFmpeg的libavformat依赖libavcodec、libavutillibswresample依赖libavutilSDL2独立。用pkg-config可以避免手写一堆-L和-l参数。pkg-config --cflags --libs libavformat libavcodec libavutil libswresample sdl2这条命令会输出类似-I/usr/include/... -lavformat -lavcodec -lavutil -lswresample -lSDL2的内容。把这段输出直接接到gcc命令后面就能编译。需要注意顺序被依赖的库放在后面所以libavutil和libavcodec要放在libavformat后面SDL2的顺序相对独立。如果编出来报undefined reference九成是库顺序问题。用CMake会更省心。pkg_check_modules把依赖转成变量target_link_libraries里变量顺序也会被保留。find_package(PkgConfig REQUIRED) pkg_check_modules(AV REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswresample) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) add_executable(audio_player main.c) target_link_libraries(audio_player PkgConfig::AV PkgConfig::SDL2)这里把FFmpeg相关库打包成一个PkgConfig::AVSDL2单独一个。IMPORTED_TARGET是CMake的新写法比直接把CFLAGS和LIBRARIES拼进target更干净。pkg_check_modules会自动处理依赖关系比如libavformat会带出libavcodec不需要你手动再写一遍。3.2 初始化解码器打开输入文件、找到音频流、把codecpar喂给解码器初始化顺序有讲究。先打开容器再找音频流再把流的参数拷贝给解码器上下文最后打开解码器。很多人漏了avcodec_parameters_to_context这一步直接拿codec_id去open结果某些MP4的AAC音轨解出来全是杂音或静音。AVFormatContext *fmt NULL; if (avformat_open_input(fmt, path, NULL, NULL) 0) { fprintf(stderr, open input failed\n); return -1; } avformat_find_stream_info(fmt, NULL); int stream_idx av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0); if (stream_idx 0) { fprintf(stderr, no audio stream\n); return -1; } AVCodecParameters *params fmt-streams[stream_idx]-codecpar; const AVCodec *codec avcodec_find_decoder(params-codec_id); AVCodecContext *dec avcodec_alloc_context3(codec); avcodec_parameters_to_context(dec, params); avcodec_open2(dec, codec, NULL);avformat_open_input只打开容器不会自动解码。av_find_best_stream选音频流比手动遍历streams然后比对codec_type要可靠它会考虑硬件解码和默认轨道。avcodec_parameters_to_context是关键一步它把codecpar里的extradata、sample_rate、ch_layout等全部拷进解码器上下文。avcodec_find_decoder根据codec_id匹配解码器比如MEDATA_TYPE_AAC会匹配到内置的AAC解码器。avcodec_open2成功后dec就准备好接收AVPacket了。解码器上下文的sample_fmt在open之后才是真实值所以重采样参数必须在open之后读取。3.3 打开SDL2音频设备SDL_OpenAudioDevice和回调函数解码器就绪之后先用解码器的参数填SDL_AudioSpec再打开设备。这里的逻辑是解码器输出什么格式SDL2就尽量按这个格式打开format统一改成AUDIO_S16SYS采样率和声道沿用解码器的值。SDL_AudioSpec want, obtained; SDL_zero(want); want.freq dec-sample_rate; want.format AUDIO_S16SYS; want.channels dec-ch_layout.nb_channels; want.samples 1024; want.callback audio_callback; want.userdata player; SDL_AudioDeviceID dev SDL_OpenAudioDevice(NULL, 0, want, obtained, 0); if (dev 0) { fprintf(stderr, open audio device failed: %s\n, SDL_GetError()); return -1; } SDL_PauseAudioDevice(dev, 0);SDL_OpenAudioDevice第五个参数传0表示不允许SDL2随意改格式。如果传SDL_AUDIO_ALLOW_FREQUENCY_CHANGESDL2可能把采样率改成它觉得更合适的值导致后面重采样目标也要跟着变。一般我会先传0万一打开失败再放宽条件。obtained是SDL2实际采用的参数可能跟want有细微差别。一定要以obtained为准比如obtained.samples可能是1024的倍数或者channels被强制改成2。回调里要用obtained而不是want来计算每次要补多少字节。SDL_PauseAudioDevice(dev, 0)让设备开始播放。SDL2默认打开设备是暂停状态忘了调用这行解码器吭哧吭哧跑半天声音就是不出来。3.4 解码主循环send_packet和receive_frame怎么配合解码循环是播放器的发动机。每次av_read_frame拿一个AVPacket判断是不是音频流是就送进解码器然后反复avcodec_receive_frame取AVFrame直到返回EAGAIN。这里的“取到为止”很重要因为一次send_packet可能对应多个AVFrame。AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); while (av_read_frame(fmt, pkt) 0) { if (pkt-stream_index stream_idx) { // 送包给解码器 int ret avcodec_send_packet(dec, pkt); if (ret 0) { fprintf(stderr, send packet failed\n); break; } // 收到所有可能的帧为止 while (avcodec_receive_frame(dec, frame) 0) { push_pcm_to_queue(player, dec, frame); av_frame_unref(frame); } } av_packet_unref(pkt); }avcodec_send_packet和avcodec_receive_frame是现代FFmpeg的推拉式接口。send_packet可能因内部buffer满而返回错误比如送包太快解码器来不及消费这时应该稍等再试而不是直接丢弃。receive_frame返回EAGAIN是正常现象表示包已经消费完但还没有完整帧输出。av_frame_unref在每帧使用后必须调否则AVFrame内部的buffer引用计数不释放跑长音频内存会持续上涨。av_packet_unref同理。这里把frame和pkt都只分配一次循环里反复复用比每帧都重新alloc要高效得多。最后记得av_packet_free和av_frame_free。push_pcm_to_queue是自定义函数内部完成重采样、拷贝进队列、通知SDL2回调线程。第4章的重采样逻辑就藏在这个函数里。4. 音频格式对齐重采样与声道映射为什么是爆音根源4.1 两种采样格式的差异FFmpeg输出浮点planarSDL2默认要s16FFmpeg解码AAC时默认输出AV_SAMPLE_FMT_FLTP也就是浮点型、平面布局。这意味着一帧数据里左声道全部数据在data[0]右声道全部数据在data[1]每个样本占4字节。SDL2这边要的AUDIO_S16SYS是双声道交错布局一个左样本、一个右样本紧挨着每个样本占2字节。直接拿FFmpeg的data[0]传给SDL2会出大问题。SDL2按s16解析float数据每个float的前两个字节被当成一个样本后两个字节被当成另一个样本声音变成高频白噪。这就是很多人第一次跑通后听到的“滋滋”声。const char *fmt_name av_get_sample_fmt_name(frame-format); // 输出例如 fltp 或 s16p fprintf(stderr, decoder output: %s, %d Hz, %d channels\n, fmt_name, frame-sample_rate, frame-ch_layout.nb_channels);这段日志应该在拿到第一个AVFrame后打印一次。我习惯把这个日志放在每次打开文件之后用ffplay参数做交叉验证确保FFmpeg解码信息没有读错。frame-format表示的是解码器实际输出的AVSampleFormat不是target的。fltp和s16p差一个字母网上很多老帖子里写的“s16p”其实是平面s16跟AUDIO_S16SYS也完全不一样。4.2 swr_convert重采样从初始化到每一帧转换重采样由libswresample完成。初始化时告诉它输入格式、输出格式、输入采样率、输出采样率。FFmpeg新版本用AVChannelLayout描述声道布局初始化代码比老版本的int64 mask要长但含义更清楚。SwrContext *swr NULL; AVChannelLayout out_layout AV_CHANNEL_LAYOUT_STEREO; AVChannelLayout in_layout; av_channel_layout_default(in_layout, frame-ch_layout.nb_channels); if (swr_alloc_set_opts2(swr, out_layout, AV_SAMPLE_FMT_S16, out_rate, in_layout, frame-format, frame-sample_rate, 0, NULL) 0) { fprintf(stderr, swr_alloc_set_opts2 failed\n); return -1; } swr_init(swr);swr_alloc_set_opts2是新版本接口老版本叫swr_alloc_set_opts参数是int64的channel_layout。out_layout写死STEREO是因为SDL2我们通常按双声道开设备。如果只想播放单声道源也可以设置AV_CHANNEL_LAYOUT_MONOSDL2会正确处理声道映射。in_layout必须和实际帧匹配从frame-ch_layout拷贝最稳妥。每一帧转换时先申请输出缓冲区再调用swr_convert。这里要特别留意buffer大小计算分配小了会截断数据分配大了浪费内存。int max_out_samples frame-nb_samples * out_rate / frame-sample_rate 256; uint8_t *out_buf NULL; int out_linesize 0; av_samples_alloc(out_buf, out_linesize, out_layout.nb_channels, max_out_samples, AV_SAMPLE_FMT_S16, 0); const uint8_t **in_data (const uint8_t **)frame-extended_data; int converted swr_convert(swr, out_buf, max_out_samples, in_data, frame-nb_samples); int out_bytes converted * out_layout.nb_channels * sizeof(int16_t);av_samples_alloc会按FFmpeg的对齐要求分配内存对齐不对在某些硬件驱动上会出“噼啪”爆音。max_out_samples按采样率比例放大再加256个样本余量防止重采样后样本数略多。swr_convert返回实际输出的样本数再乘上声道数和int16_t字节数得到字节长度。注意in_data取了frame-extended_data而不是data。planar格式下extended_data指向各个平面交错格式下两者一致。用extended_data是兼容写法。converted之后out_buf里就是SDL2能直接吃的交错s16数据把它memcpy进播放队列即可。4.3 缓冲时长怎么设samples太小爆音太大延迟增加SDL_AudioSpec.samples这个参数很多人不重视随便填一个数字结果声音要么像拖拉机要么像慢放。它决定每次回调请求的PCM长度也就是设备侧缓冲的粒度。下表是44100Hz双声道s16格式下的估算值。samples每次回调时长回调频率适合场景512约11.6ms约86次/秒低延迟监听容易爆音1024约23.2ms约43次/秒普通播放器兼顾延迟2048约46.4ms约21次/秒弱机器画面同步要求不高4096约92.9ms约11次/秒纯音频工具缓存优先1024是多数场景的平衡点。samples太小音频线程必须非常准时地喂数据主线程稍微被调度打断一次队列就空了声音出现停顿。samples太大播放延迟高后续做A/V同步时音频时钟会比视频时钟慢一大截为了对上嘴唇动作还得额外做拖延处理。我的经验是本地播放器用1024或者2048直播类低延迟工具用512但是要配合解码线程的实时优先级后台转码工具用4096反正不看画面尽量让音频连续。确定值之后最好用obtained.samples确认SDL2有没有改你的参数如果SDL2偷偷改成了4096你的延迟预期就全错了。5. 播放音频常见问题与避坑排查从卡顿到无声的5个实例5.1 现象一声音一卡一卡CPU占用却不高问题在队列饥饿现象歌曲播放时每隔一两秒顿一下系统监控显示CPU占用不到20%解码线程看起来也没在忙。原因这是队列饥饿。SDL2回调拿数据时队列是空的只能填静音。产生空队列的原因很多最常见的是SD_PauseAudioDevice之后没有等设备真正开始消费就进入复杂初始化逻辑或者是解码线程在某个加锁的队列满时sleep太久回调线程这段期间一直空转。解决解码线程里队列达到上限时用SDL_CondTimedWait而不是SDL_Delay超时时间缩短到10毫秒以内让解码线程尽快恢复生产。同时给队列一个最小水位低于这个水位时判断为“欠载”播放器主动把当前缓冲清空一次防止积累的静音造成后续跳变。欠载日志要打出来否则很难判断是解码慢还是回调慢。// 队列满时等待条件变量而不是粗暴延迟 while (player.queue_len MAX_QUEUE_BYTES !player.quit) { SDL_CondTimedWait(player.cond, player.lock, 10); }SDL_CondTimedWait第三个参数是毫秒10毫秒是经验值。设成0会无限等待如果解码线程或者主线程忘了通知播放就永久卡死。设成100毫秒又太长回调线程早就把静音播出去了。10毫秒既避免忙等占CPU又能及时恢复。5.2 现象二有声音但全是沙沙声波形像白噪采样格式没对齐现象能听到节奏也能分辨音量变化但整体是“滋滋”的白噪完全不像音乐。原因这是把fltp浮点数据按s16整数解读了。浮点的低位字节在s16视角里全是随机数所以听起来像白噪。另一个类似的原因是交错和平面混淆frame-data[0]拿来直接用没做swr_convert。解决在写入队列前强制走一遍swr_convert目标格式固定为AV_SAMPLE_FMT_S16。同时打印解码器输出格式和重采样目标格式两行日志一对比就能看出问题。如果解码器输出是s16p即平面s16也要走swr_convert因为SDL2要的是交错布局。if (frame-format ! AV_SAMPLE_FMT_S16) { // 必须重采样不能直接拷贝 swr_convert(swr, out_buf, max_out_samples, (const uint8_t **)frame-extended_data, frame-nb_samples); }有人会把这里的条件写成frame-format ! dec-sample_fmt这是不对的。dec-sample_fmt是解码器上下文声明的格式frame-format才是实际输出的格式两者大多数情况一致但不排除个别封装在运行中切换格式。用frame-format做判断更严谨。5.3 现象三在SDL2回调里做解码声音越播越慢直到彻底卡死现象刚开始播放正常过几秒声音变慢、变钝像是磁带被拉住最后完全无声。原因回调线程里做了avcodec_send_packet或阻塞式队列等待。SDL2的音频回调运行在实时音频线程不允许阻塞。解码一个帧可能要几毫秒到几十毫秒回调期间声卡buffer已经空了SDL2没法按时返回数据系统就开始丢数据表现出来就是越来越慢。解决回调里只做从队列取数据和拷贝所有解码和重采样放在独立线程。队列空时直接填静音返回绝不在回调里等一下数据。void audio_callback(void *userdata, Uint8 *stream, int len) { AudioPlayer *p (AudioPlayer *)userdata; SDL_memset(stream, 0, len); // 先静音兜底 SDL_LockMutex(p-lock); if (p-queue_len 0) { int copy_len (len p-queue_len) ? len : p-queue_len; SDL_memcpy(stream, p-queue_head, copy_len); p-queue_head copy_len; p-queue_len - copy_len; } SDL_UnlockMutex(p-lock); }先memset静音是重要细节。队列数据不够时stream剩余部分必须是静音如果留着上次的旧数据听起来会是一段重复噪音。len是字节数不是样本数所以queue_len也按字节管理。这个回调总共就做一个拷贝速度很快不会拖垮音频线程。5.4 现象四MP4能出画面没有声音解码参数没从codecpar同步过去现象同一个MP4文件视频轨道能正常解码显示音频设备也打开了但整个播放过程没有任何声音日志里也没有报错。原因初始化解码器时漏了avcodec_parameters_to_context。MP4封装里的AAC音轨必要的解码参数像SBR、PS信息都放在extradata里只有调用了这个函数extradata才会被拷贝到AVCodecContext。漏掉之后解码器open成功但处理不了这类增强配置输出的全是空白帧或解析失败帧。解决初始化顺序必须是avcodec_alloc_context3、avcodec_parameters_to_context、avcodec_open2。绝对不能只拿codec_id就open。AVCodecParameters *params fmt-streams[stream_idx]-codecpar; const AVCodec *codec avcodec_find_decoder(params-codec_id); AVCodecContext *dec avcodec_alloc_context3(codec); // 这行不能省extradata全靠它 avcodec_parameters_to_context(dec, params); avcodec_open2(dec, codec, NULL);顺带检查一下avcodec_open2的返回值很多情况是extradata有但解码器配置不匹配open会返回具体错误码。用av_strerror把错误码转成文字排查效率会高很多。5.5 现象五暂停恢复后开头重复一段队列残留旧数据现象按暂停键停了十秒恢复播放后开头处重复播放了一两秒内容然后才跳到正确位置。原因暂停操作SDL2设备停止消费但解码线程如果设计得不够严谨还会继续往队列里塞PCM。恢复播放时队列里保留的旧PCM先被播出去用户听到的就是暂停前的那一段内容。如果暂停时间长解码线程可能已经把后续内容都解出来了恢复后反而跳过中间内容。解决暂停时除了SDL_PauseAudioDevice(dev, 1)还要让解码线程停止生产并清空队列。恢复时先清队列再让设备恢复播放。最简单的做法是在播放器状态机里增加PLAYING和PAUSED两个状态解码线程在PAUSED状态下只解不塞把帧丢弃。void audio_pause(AudioPlayer *p) { SDL_LockMutex(p-lock); p-queue_head 0; p-queue_len 0; // 清空残留 PCM p-paused 1; SDL_UnlockMutex(p-lock); SDL_PauseAudioDevice(p-dev, 1); }paused标志让解码线程知道接下来产生的帧不能入队。恢复时再把paused置0SDL_PauseAudioDevice(dev, 0)。清空队列要加锁否则回调线程可能正从旧位置取数据产生读取越界。这个清空操作是血泪经验不加锁的版本在Windows和Linux的ALSA后端下都偶发崩溃。6. 从能出声到能交付播放进度与音量控制的落地验证6.1 用播放进度对账来验证整条管线不只是听个响能出声只算第一步交付前我会做一次“进度对账”验证解码速度和播放速度是否一致。具体做法是在音频回调里累计已播放的字节数换算成毫秒再跟SDL_GetTicks经过的时间对比。两者差值保持在几百毫秒内且不持续扩大说明整条管线是健康的。long played_ms player.played_bytes * 1000 / (obtained.freq * obtained.channels * 2); long elapsed_ms SDL_GetTicks() - player.start_ticks; fprintf(stderr, played%ldms elapsed%ldms diff%ldms\n, played_ms, elapsed_ms, played_ms - elapsed_ms);played_bytes来自回调里每次拷贝的len累加注意它少算了一部分设备内部缓冲。所以diff略负是正常的。如果diff持续向正方向增长说明播放比现实时间慢解码线程跟不上如果diff负得越来越多说明缓冲过大延迟难控制。我一般以这个差值作为是否需要调samples的量化依据。音量控制我的习惯是在回调里用SDL_MixAudioFormat它处理了整数饱和和格式转换比自己拿short指针乘个系数可靠得多。先memset输出为静音再把PCM数据以指定音量混入。SDL_memset(stream, 0, len); if (data_len 0) { SDL_MixAudioFormat(stream, data, AUDIO_S16SYS, data_len, volume); }volume取值范围0到SDL_MIX_MAXVOLUME也就是0到128。SDL_MixAudioFormat把第二个参数的数据按volume缩放后加到第一个参数上所以第一个参数必须先清零否则会叠加上一次回调度残留的内容。这个函数不改变数据长度也不会爆音比手动做音量朴素实现要省心。播放进度对账是我现在每次交付前的固定动作跑一首四分钟的歌看diff曲线是否走平。很多“偶尔卡一下”的隐性问题靠耳朵很难复现但进度差曲线会诚实地暴露间歇欠载。搞定了这一步播放音频这个模块才算从demo变成可交付的组件。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑