1. 视频播放器基本原理
下图引用自“雷霄骅,视音频编解码技术零基础学习方法”,因原图太小,看不太清楚,故重新制作了一张图片。
如下内容引用自“雷霄骅,视音频编解码技术零基础学习方法”:
解协议 将流媒体协议的数据,解析为标准的相应的封装格式数据。视音频在网络上传播的时候,常常采用各种流媒体协议,例如HTTP,RTMP,或是MMS等等。这些协议在传输视音频数据的同时,也会传输一些信令数据。这些信令数据包括对播放的控制(播放,暂停,停止),或者对网络状态的描述等。解协议的过程中会去除掉信令数据而只保留视音频数据。例如,采用RTMP协议传输的数据,经过解协议操作后,输出FLV格式的数据。 解封装 将输入的封装格式的数据,分离成为音频流压缩编码数据和视频流压缩编码数据。封装格式种类很多,例如MP4,MKV,RMVB,TS,FLV,AVI等等,它的作用就是将已经压缩编码的视频数据和音频数据按照一定的格式放到一起。例如,FLV格式的数据,经过解封装操作后,输出H.264编码的视频码流和AAC编码的音频码流。 解码 将视频/音频压缩编码数据,解码成为非压缩的视频/音频原始数据。音频的压缩编码标准包含AAC,MP3,AC-3等等,视频的压缩编码标准则包含H.264,MPEG2,VC-1等等。解码是整个系统中最重要也是最复杂的一个环节。通过解码,压缩编码的视频数据输出成为非压缩的颜色数据,例如YUV420P,RGB等等;压缩编码的音频数据输出成为非压缩的音频抽样数据,例如PCM数据。 音视频同步 根据解封装模块处理过程中获取到的参数信息,同步解码出来的视频和音频数据,并将视频音频数据送至系统的显卡和声卡播放出来。
2. 简易播放器的实现-音频播放
2.1 实验平台
实验平台:openSUSE Leap 42.3
FFmpeg版本:4.1
SDL版本:2.0.9
FFmpeg开发环境搭建可参考“ffmpeg开发环境构建”
2.2 源码流程分析
本实验仅播放视频文件中的声音,而不显示图像。源码流程参考如下:
2.3 源码清单
代码已经变得挺长了,不贴完整源码了,源码参考:
https://github.com/leichn/exercises/blob/master/source/ffmpeg/player_audio/ffplayer.c
源码清单中涉及的一些概念简述如下:
container:
对应数据结构AVFormatContext
封装器,将流数据封装为指定格式的文件,文件格式如AVI、MP4等。
FFmpeg可识别五种流类型:视频video(v)、音频audio(a)、attachment(t)、数据data(d)、字幕subtitle。
codec:
对应数据结构AVCodec
编解码器。编码器将未压缩的原始图像或音频数据编码为压缩数据。解码器与之相反。
codec context:
对应数据结构AVCodecContext
编解码器上下文。此为非常重要的一个数据结构,后文分析。各API大量使用AVCodecContext来引用编解码器。
codec par:
对应数据结构AVCodecParameters
编解码器参数。新版本增加的字段。新版本建议使用AVStream->codepar替代AVStream->codec。
packet:
对应数据结构AVPacket
经过编码的数据。通过av_read_frame()从媒体文件中获取得到的一个packet可能包含多个(整数个)音频帧或单个
视频帧,或者其他类型的流数据。
frame:
对应数据结构AVFrame
解码后的原始数据。解码器将packet解码后生成frame。
2.4 关键过程
几个关键函数的说明直接写在代码注释里:
2.4.1 开启音频处理子线程
代码语言:javascript复制// B2. 打开音频设备并创建音频处理线程
// B2.1 打开音频设备,获取SDL设备支持的音频参数actual_spec(期望的参数是wanted_spec,实际得到actual_spec)
// 1) SDL提供两种使音频设备取得音频数据方法:
// a. push,SDL以特定的频率调用回调函数,在回调函数中取得音频数据
// b. pull,用户程序以特定的频率调用SDL_QueueAudio(),向音频设备提供数据。此种情况wanted_spec.callback=NULL
// 2) 音频设备打开后播放静音,不启动回调,调用SDL_PauseAudio(0)后启动回调,开始正常播放音频
wanted_spec.freq = p_codec_ctx->sample_rate; // 采样率
wanted_spec.format = AUDIO_S16SYS; // S表带符号,16是采样深度,SYS表采用系统字节序
wanted_spec.channels = p_codec_ctx->channels; // 声道数
wanted_spec.silence = 0; // 静音值
wanted_spec.samples = SDL_AUDIO_BUFFER_SIZE; // SDL声音缓冲区尺寸,单位是单声道采样点尺寸x通道数
wanted_spec.callback = sdl_audio_callback; // 回调函数,若为NULL,则应使用SDL_QueueAudio()机制
wanted_spec.userdata = p_codec_ctx; // 提供给回调函数的参数
if (SDL_OpenAudio(&wanted_spec, &actual_spec) < 0)
{
printf("SDL_OpenAudio() failed: %sn", SDL_GetError());
goto exit4;
}
// B2.2 根据SDL音频参数构建音频重采样参数
// wanted_spec是期望的参数,actual_spec是实际的参数,wanted_spec和auctual_spec都是SDL中的参数。
// 此处audio_param是FFmpeg中的参数,此参数应保证是SDL播放支持的参数,后面重采样要用到此参数
// 音频帧解码后得到的frame中的音频格式未必被SDL支持,比如frame可能是planar格式,但SDL2.0并不支持planar格式,
// 若将解码后的frame直接送入SDL音频缓冲区,声音将无法正常播放。所以需要先将frame重采样(转换格式)为SDL支持的模式,
// 然后送再写入SDL音频缓冲区
s_audio_param_tgt.fmt = AV_SAMPLE_FMT_S16;
s_audio_param_tgt.freq = actual_spec.freq;
s_audio_param_tgt.channel_layout = av_get_default_channel_layout(actual_spec.channels);;
s_audio_param_tgt.channels = actual_spec.channels;
s_audio_param_tgt.frame_size = av_samples_get_buffer_size(NULL, actual_spec.channels, 1, s_audio_param_tgt.fmt, 1);
s_audio_param_tgt.bytes_per_sec = av_samples_get_buffer_size(NULL, actual_spec.channels, actual_spec.freq, s_audio_param_tgt.fmt, 1);
if (s_audio_param_tgt.bytes_per_sec <= 0 || s_audio_param_tgt.frame_size <= 0)
{
printf("av_samples_get_buffer_size failedn");
goto exit4;
}
s_audio_param_src = s_audio_param_tgt;
2.4.2 启动音频回调机制
代码语言:javascript复制// 暂停/继续音频回调处理。参数1表暂停,0表继续。
// 打开音频设备后默认未启动回调处理,通过调用SDL_PauseAudio(0)来启动回调处理。
// 这样就可以在打开音频设备后先为回调函数安全初始化数据,一切就绪后再启动音频回调。
// 在暂停期间,会将静音值往音频设备写。
SDL_PauseAudio(0);
2.4.3 音频回调函数
用户实现的函数,由SDL音频处理子线程回调
代码语言:javascript复制// 音频处理回调函数。读队列获取音频包,解码,播放
// 此函数被SDL按需调用,此函数不在用户主线程中,因此数据需要保护
// param[in] userdata用户在注册回调函数时指定的参数
// param[out] stream 音频数据缓冲区地址,将解码后的音频数据填入此缓冲区
// param[out] len 音频数据缓冲区大小,单位字节
// 回调函数返回后,stream指向的音频缓冲区将变为无效
// 双声道采样点的顺序为LRLRLR
void audio_callback(void *userdata, uint8_t *stream, int len)
{
...
}
2.4.4 音频包队列读写函数
用户实现的函数,主线程向队列尾部写音频包,SDL音频处理子线程(回调函数处理)从队列头部取出音频包
代码语言:javascript复制// 写队列尾部
int packet_queue_push(packet_queue_t *q, AVPacket *pkt)
{
...
}
// 读队列头部
int packet_queue_pop(packet_queue_t *q, AVPacket *pkt, int block)
{
...
}
2.4.5 音频解码
音频解码功能封装为一个函数,将一个音频packet解码后得到的声音数据传递给输出缓冲区。此处的输出缓冲区audio_buf会由上一级调用函数audio_callback()在返回时将缓冲区数据提供给音频设备。
代码语言:javascript复制int audio_decode_frame(AVCodecContext *p_codec_ctx, AVPacket *p_packet, uint8_t *audio_buf, int buf_size)
{
AVFrame *p_frame = av_frame_alloc();
int frm_size = 0;
int res = 0;
int ret = 0;
int nb_samples = 0; // 重采样输出样本数
uint8_t *p_cp_buf = NULL;
int cp_len = 0;
bool need_new = false;
res = 0;
while (1)
{
need_new = false;
// 1 接收解码器输出的数据,每次接收一个frame
ret = avcodec_receive_frame(p_codec_ctx, p_frame);
if (ret != 0)
{
if (ret == AVERROR_EOF)
{
printf("audio avcodec_receive_frame(): the decoder has been fully flushedn");
res = 0;
goto exit;
}
else if (ret == AVERROR(EAGAIN))
{
//printf("audio avcodec_receive_frame(): output is not available in this state - "
// "user must try to send new inputn");
need_new = true;
}
else if (ret == AVERROR(EINVAL))
{
printf("audio avcodec_receive_frame(): codec not opened, or it is an encodern");
res = -1;
goto exit;
}
else
{
printf("audio avcodec_receive_frame(): legitimate decoding errorsn");
res = -1;
goto exit;
}
}
else
{
// s_audio_param_tgt是SDL可接受的音频帧数,是main()中取得的参数
// 在main()函数中又有“s_audio_param_src = s_audio_param_tgt”
// 此处表示:如果frame中的音频参数 == s_audio_param_src == s_audio_param_tgt,那音频重采样的过程就免了(因此时s_audio_swr_ctx是NULL)
// 否则使用frame(源)和s_audio_param_src(目标)中的音频参数来设置s_audio_swr_ctx,并使用frame中的音频参数来赋值s_audio_param_src
if (p_frame->format != s_audio_param_src.fmt ||
p_frame->channel_layout != s_audio_param_src.channel_layout ||
p_frame->sample_rate != s_audio_param_src.freq)
{
swr_free(&s_audio_swr_ctx);
// 使用frame(源)和is->audio_tgt(目标)中的音频参数来设置is->swr_ctx
s_audio_swr_ctx = swr_alloc_set_opts(NULL,
s_audio_param_tgt.channel_layout,
s_audio_param_tgt.fmt,
s_audio_param_tgt.freq,
p_frame->channel_layout,
p_frame->format,
p_frame->sample_rate,
0,
NULL);
if (s_audio_swr_ctx == NULL || swr_init(s_audio_swr_ctx) < 0)
{
printf("Cannot create sample rate converter for conversion of %d Hz %s %d channels to %d Hz %s %d channels!n",
p_frame->sample_rate, av_get_sample_fmt_name(p_frame->format), p_frame->channels,
s_audio_param_tgt.freq, av_get_sample_fmt_name(s_audio_param_tgt.fmt), s_audio_param_tgt.channels);
swr_free(&s_audio_swr_ctx);
return -1;
}
// 使用frame中的参数更新s_audio_param_src,第一次更新后后面基本不用执行此if分支了,因为一个音频流中各frame通用参数一样
s_audio_param_src.channel_layout = p_frame->channel_layout;
s_audio_param_src.channels = p_frame->channels;
s_audio_param_src.freq = p_frame->sample_rate;
s_audio_param_src.fmt = p_frame->format;
}
if (s_audio_swr_ctx != NULL) // 重采样
{
// 重采样输入参数1:输入音频样本数是p_frame->nb_samples
// 重采样输入参数2:输入音频缓冲区
const uint8_t **in = (const uint8_t **)p_frame->extended_data;
// 重采样输出参数1:输出音频缓冲区尺寸
// 重采样输出参数2:输出音频缓冲区
uint8_t **out = &s_resample_buf;
// 重采样输出参数:输出音频样本数(多加了256个样本)
int out_count = (int64_t)p_frame->nb_samples * s_audio_param_tgt.freq / p_frame->sample_rate 256;
// 重采样输出参数:输出音频缓冲区尺寸(以字节为单位)
int out_size = av_samples_get_buffer_size(NULL, s_audio_param_tgt.channels, out_count, s_audio_param_tgt.fmt, 0);
if (out_size < 0)
{
printf("av_samples_get_buffer_size() failedn");
return -1;
}
if (s_resample_buf == NULL)
{
av_fast_malloc(&s_resample_buf, &s_resample_buf_len, out_size);
}
if (s_resample_buf == NULL)
{
return AVERROR(ENOMEM);
}
// 音频重采样:返回值是重采样后得到的音频数据中单个声道的样本数
nb_samples = swr_convert(s_audio_swr_ctx, out, out_count, in, p_frame->nb_samples);
if (nb_samples < 0) {
printf("swr_convert() failedn");
return -1;
}
if (nb_samples == out_count)
{
printf("audio buffer is probably too smalln");
if (swr_init(s_audio_swr_ctx) < 0)
swr_free(&s_audio_swr_ctx);
}
// 重采样返回的一帧音频数据大小(以字节为单位)
p_cp_buf = s_resample_buf;
cp_len = nb_samples * s_audio_param_tgt.channels * av_get_bytes_per_sample(s_audio_param_tgt.fmt);
}
else // 不重采样
{
// 根据相应音频参数,获得所需缓冲区大小
frm_size = av_samples_get_buffer_size(
NULL,
p_codec_ctx->channels,
p_frame->nb_samples,
p_codec_ctx->sample_fmt,
1);
printf("frame size %d, buffer size %dn", frm_size, buf_size);
assert(frm_size <= buf_size);
p_cp_buf = p_frame->data[0];
cp_len = frm_size;
}
// 将音频帧拷贝到函数输出参数audio_buf
memcpy(audio_buf, p_cp_buf, cp_len);
res = cp_len;
goto exit;
}
// 2 向解码器喂数据,每次喂一个packet
if (need_new)
{
ret = avcodec_send_packet(p_codec_ctx, p_packet);
if (ret != 0)
{
printf("avcodec_send_packet() failed %dn", ret);
av_packet_unref(p_packet);
res = -1;
goto exit;
}
}
}
exit:
av_frame_unref(p_frame);
return res;
}
注意:
1. 一个音频packet中含有多个完整的音频帧,此函数每次只返回一个frame,当avcodec_receive_frame()指示需要新数据时才调用avcodec_send_packet()向编码器发送一个packet。
2. 音频frame中的数据格式未必被SDL支持,对于不支持的音频frame格式,需要进行重采样,转换为SDL支持的格式声音才能正常播放
3. 解码器内部会有缓冲机制,会缓存一定量的音频帧,不冲洗(flush)解码器的话,缓存帧是取不出来的,未冲洗(flush)解码器情况下,avcodec_receive_frame()返回AVERROR(EAGAIN),表示解码器中改取的帧已取完了(当然缓存帧还是在的),需要用avcodec_send_packet()向解码器提供新数据。
4. 文件播放完毕时,应冲洗(flush)解码器。冲洗(flush)解码器的方法就是调用avcodec_send_packet(..., NULL),然后按之前同样的方式多次调用avcodec_receive_frame()将缓存帧取尽。缓存帧取完后,avcodec_receive_frame()返回AVERROR_EOF。
3. 编译与验证
3.1 编译
代码语言:javascript复制gcc -o ffplayer ffplayer.c -lavutil -lavformat -lavcodec -lavutil -lswscale -lswresample -lSDL2
3.2 验证
选用clock.avi测试文件,测试文件下载:clock.avi
查看视频文件格式信息:
代码语言:javascript复制ffprobe clock.avi
打印视频文件信息如下:
代码语言:javascript复制[avi @ 0x9286c0] non-interleaved AVI
Input #0, avi, from 'clock.avi':
Duration: 00:00:12.00, start: 0.000000, bitrate: 42 kb/s
Stream #0:0: Video: msrle ([1][0][0][0] / 0x0001), pal8, 320x320, 1 fps, 1 tbr, 1 tbn, 1 tbc
Stream #0:1: Audio: truespeech ([34][0][0][0] / 0x0022), 8000 Hz, mono, s16, 8 kb/s
运行测试命令:
代码语言:javascript复制./ffplayer clock.avi
可以听到每隔1秒播放一次“嘀”声,播放12次后播放结束。播放过程只有声音,没有图像窗口。播放正常。
4. 参考资料
1 雷霄骅,视音频编解码技术零基础学习方法
2 雷霄骅,最简单的基于FFMPEG SDL的视频播放器ver2(采用SDL2.0)
3 SDL WIKI, https://wiki.libsdl.org/
4 Martin Bohme, An ffmpeg and SDL Tutorial, Tutorial 03: Playing Sound
5. 修改记录
2018-12-04 V1.0 初稿
2019-01-06 V1.1 增加音频重采样,修复部分音频格式无法正常播放的问题