iOS 音频开发一般是 AudioFileStream 配合 Audio Queue 或者 Audio Unit实现的,而 FFmpeg 是与原生截然不同的软解码实现,比起原生更支持了诸如 flac、ape、acc、m4a、mp3、wav等格式(原生可通过第三方支持 flac 等格式)。因为工作项目的音乐播放器是原生那套实现的,所以就想换个思路,看看用 FFmpeg 开发音频是怎样的体验。
我对 FFmpeg 的探索起初是从简单 demo 入坑,发现大部分 demo 都需要 sdl,再去了解过 sdl,可谓踩了不少坑,直到我上手 kxmovie、ijkplayer,思路才渐渐清晰起来。经过对比,ijkplayer 是基于 c 实现的项目,实现了跨平台,而 kxmovie 是偏 iOS 实现的项目;kxmovie 在播放 m3u8 有点卡顿的bug,而 ijkplayer 则没有。可知 kxmovie 无论是代码兼容性还是播放视频的效果都没有 ijkplayer 好。ijkplayer 是 Bilibili 开源的一个项目,它基于 FFmpeg 开发了支持移动端音视频解码、视频渲染、播放控制、状态监控等功能。通过 ijkplayer 源码的学习,我对 FFmpeg 有了大致了解,而本文主要聊聊音频相关的。
FFmpeg 的导入
ijkplayer ReadMe 里有关如何编译 FFmpeg 的介绍,只要注意下是否支持更多解码格式即可,将生成出的 .a 文件拖进项目里,为项目添加 libbz.tbd,libbz2.tbd ,再在Build Settings
的Library Search Paths
添加 项目中 FFmpeg 的.a文件的目录,如 $(PROJECT_DIR)/your_project_name/ffmpeg/lib
,FFmpeg 的导入完成了。
代码处理流程大致介绍
我只挑了几个有意思的来总结下,像 ffplay.c 一来就两三千行代码,虽然流程都是套路,但是不记录一下的话,时间久了,挺容易没什么头绪。
负责底层调用的 ffplay.c,首先注册解码器,初始化 FFPlayer 和 VideoState 开启一条线程调用 read_thread
函数。在函数中调用avformat_open_input
打开多媒体文件,打开文件后avformat_find_stream_info
获取文件中的流信息填充进为ic->streams
,获取流信息后使用av_find_best_stream
获取文件的音频和视频流,并准备对音频和视频信息进行解码。接着调用stream_component_open
函数,通过avcodec_find_decoder
找到codec_id
已注册的音视频解码器,再就是avcodec_open2
打开解码器准备音视频的解码,再从audio_open
开启sdl_audio_callback
回调。此时在read_thread
函数中会循环读取av_read_frame(ic, pkt)
包数据,并将包数据存入包队列以供解码时使用。而对于音频解码会起新的线程调用audio_thread
(视频则是video_thread
),取出包数据后,使用avcodec_decode_audio4
将解码后的 Frame 交给帧队列。sdl_audio_callback
里的audio_decode_frame
负责从帧队列中取出 Frame frame_queue_peek_readable(&is->sampq)
,完成重采样后,将 data 通过memcpy
拷贝的方式回调给高层使用。
ijkplayer.c 是对 ffplay.c 的封装,包括播放暂停,获取文件时长,可播放时长,seek到特定时间点播放等,也实现了播放器的状态的监听。
ijksdl_aout_ios_audiounit
与ijksdl_aout
的设计挺有趣,它们共同串联了从高层到 FFmpeg 层的操作,通过指针函数,在ijksdl_aout_ios_audiounit
注册了高层音频调用实现,ijksdl_aout
负责供 FFmpeg 调用,从而达到解藕的效果。
实践
先来张效果图
基于边学习边动手的原则,我完成了一个仅支持音频播放的 demo,因为仅仅是支持音频,demo中对 ijkplayer 的几个文件做了点修改,比如剔除原来视频相关的代码,修改其仅从音频文件中取出封面
1 2 3 4 5 6 7 8 9 |
.... switch (d->avctx->codec_type) { case AVMEDIA_TYPE_VIDEO: if (d->pkt_temp.data && d->pkt_temp.size) { ffp->artist_data = d->pkt_temp.data; ffp->artist_size = d->pkt_temp.size; ffp->cover_data(ffp); } break; .... |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void audio_cover_data(uint8_t *data, int size) { UIImage *image = nil; NSData *imgData = [NSData dataWithBytes:data length:size]; CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)(imgData)); if (provider) { CGImageRef imageRef = CGImageCreateWithJPEGDataProvider(provider, NULL, YES, kCGRenderingIntentDefault); if (imageRef) { image = [UIImage imageWithCGImage:imageRef]; CGImageRelease(imageRef); } CGDataProviderRelease(provider); } } |
总结
在学习和使用中还是发现了一点小遗憾,FFmpeg 还没有提供对 io 层的缓存支持,这导致了在播放网络文件的时候拖拽进度条会重新进行缓冲,也无法实现边播边存的功能,我尝试过获取pkt的data并在av_read_frame
的ret<0文件结束的情况下缓存起来,但这其实并没能保证帧顺序,拖拽过进度条之后的data也将会不完整,所以放弃了这种方案,官方的说法是在 libavformat/cache.c 中进行实现。
有个小问题是对于缓冲进度的计算有点小误差,playableDuration 无法与多媒体文件时长一致。我尝试在av_read_frame
文件结束的地方
1 2 3 4 |
SDL_LockMutex(wait_mutex); SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); SDL_UnlockMutex(wait_mutex); ffp_statistic_l(ffp); |
之后发送一个通知,直接将缓冲进度条置为100%
还有个小问题是 ijkplayer 播放本地文件的时候,假如是一首2、30m的无损歌曲,播放器不会一下子把整个文件都解码进内存中,这时当seek到文件未进入到内存的部分,播放器就直接停止播放了,我试过修改MAX_QUEUE_SIZE
的值也是无效,最后 bbcallen 回复我说:That should be an ffmpeg issue, take a look at the comment of avformat_seek_file(),如此看来也是暂时解决无望。
在 ijkplayer 的 某个issue 中看到了 bbcallen 貌似说b站的客户端也用系统原生来播放视频,我觉得iOS
播放视频的话使用MPMoviePlayerController
就好了。
当然,如果是直播项目,无疑是 FFmpeg 的用武之地。