如今一个 APP 玩的花样是愈来愈多了几乎都离不开音频、视频、图片等数据显示,该篇就介绍其中的音视频播放,音视频播放能够用已经成熟开源的播放器,(推荐一个不错的播放器开源项目GSYVideoPlayer)。若是用已开源的播放器就没有太大的学习意义了,该篇文章会介绍基于 FFmpeg 4.2.2 、Librtmp 库从 0~1 开发一款 Android 播放器的流程和实例代码编写。java
开发一款播放器你首先要具有的知识有:linux
- FFmpeg RTMP 混合交叉编译
- C/C++ 基础
- NDK、JNI
- 音视频解码、同步
学完以后咱们的播放器大概效果以下:android
效果看起来有点卡,这跟实际网络环境有关,此播放器已具有 rtmp/http/URL/File 等协议播放。c++
介绍:git
RTMP 是 Real Time Messaging Protocol(实时消息传输协议)的首字母缩写。该协议基于 TCP,是一个协议族,包括 RTMP 基本协议及 RTMPT/RTMPS/RTMPE 等多种变种。RTMP 是一种设计用来进行实时数据通讯的网络协议,主要用来在 Flash/AIR 平台和支持 RTMP 协议的流媒体/交互服务器之间进行音视频和数据通讯。支持该协议的软件包括 Adobe Media Server/Ultrant Media Server/red5 等。RTMP 与 HTTP 同样,都属于 TCP/IP 四层模型的应用层。github
下载:shell
git clone https://github.com/yixia/librtmp.git
复制代码
脚本编写:api
#!/bin/bash
#配置NDK 环境变量
NDK_ROOT=$NDK_HOME
#指定 CPU
CPU=arm-linux-androideabi
#指定 Android API
ANDROID_API=17
TOOLCHAIN=$NDK_ROOT/toolchains/$CPU-4.9/prebuilt/linux-x86_64
export XCFLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=$ANDROID_API"
export XLDFLAGS="--sysroot=${NDK_ROOT}/platforms/android-17/arch-arm "
export CROSS_COMPILE=$TOOLCHAIN/bin/arm-linux-androideabi-
make install SYS=android prefix=`pwd`/result CRYPTO= SHARED= XDEF=-DNO_SSL
复制代码
若是出现以下效果就证实编译成功了:bash
上一篇文章我们编译了 FFmpeg 静态库,那么该小节我们要把 librtmp 集成到 FFmpeg 中编译,首先咱们须要到 configure 脚本中把 librtmp 模块注释掉,以下:服务器
修改 FFmpeg 编译脚本:
#!/bin/bash
#NDK_ROOT 变量指向ndk目录
NDK_ROOT=$NDK_HOME
#TOOLCHAIN 变量指向ndk中的交叉编译gcc所在的目录
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64
#指定android api版本
ANDROID_API=17
#此变量用于编译完成以后的库与头文件存放在哪一个目录
PREFIX=./android/armeabi-v7a
#rtmp路径
RTMP=/root/android/librtmp/result
#执行configure脚本,用于生成makefile
#--prefix : 安装目录
#--enable-small : 优化大小
#--disable-programs : 不编译ffmpeg程序(命令行工具),咱们是须要得到静态(动态)库。
#--disable-avdevice : 关闭avdevice模块,此模块在android中无用
#--disable-encoders : 关闭全部编码器 (播放不须要编码)
#--disable-muxers : 关闭全部复用器(封装器),不须要生成mp4这样的文件,因此关闭
#--disable-filters :关闭视频滤镜
#--enable-cross-compile : 开启交叉编译
#--cross-prefix: gcc的前缀 xxx/xxx/xxx-gcc 则给xxx/xxx/xxx-
#disable-shared enable-static 不写也能够,默认就是这样的。
#--sysroot:
#--extra-cflags: 会传给gcc的参数
#--arch --target-os : 必需要给
./configure \
--prefix=$PREFIX \
--enable-small \
--disable-programs \
--disable-avdevice \
--disable-encoders \
--disable-muxers \
--disable-filters \
--enable-librtmp \
--enable-cross-compile \
--cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
--disable-shared \
--enable-static \
--sysroot=$NDK_ROOT/platforms/android-$ANDROID_API/arch-arm \
--extra-cflags="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security -O0 -fPIC -I$RTMP/include" \
--extra-ldflags="-L$RTMP/lib" \
--extra-libs="-lrtmp" \
--arch=arm \
--target-os=android
#上面运行脚本生成makefile以后,使用make执行脚本
make clean
make
make install
复制代码
若是出现以下,证实开始编译了:
若是出现以下,证实编译成功了:
能够从上图中看到静态库和头文件库都已经编译成功了,下面咱们就进入编写代码环节了。
想要实现一个网络/本地播放器,咱们必须知道它的流程,以下图所示:
建立一个新的 Android 项目并导入各自库
CmakeLists.txt 编译脚本编写
cmake_minimum_required(VERSION 3.4.1)
#定义 ffmpeg、rtmp 、yk_player 目录
set(FFMPEG ${CMAKE_SOURCE_DIR}/ffmpeg)
set(RTMP ${CMAKE_SOURCE_DIR}/librtmp)
set(YK_PLAYER ${CMAKE_SOURCE_DIR}/player)
#指定 ffmpeg 头文件目录
include_directories(${FFMPEG}/include)
#指定 ffmpeg 静态库文件目录
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${FFMPEG}/libs/${CMAKE_ANDROID_ARCH_ABI}")
#指定 rtmp 静态库文件目录
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${RTMP}/libs/${CMAKE_ANDROID_ARCH_ABI}")
#批量添加本身编写的 cpp 文件,不要把 *.h 加入进来了
file(GLOB ALL_CPP ${YK_PLAYER}/*.cpp)
#添加本身编写 cpp 源文件生成动态库
add_library(YK_PLAYER SHARED ${ALL_CPP})
#找系统中 NDK log库
find_library(log_lib
log)
#最后才开始连接库
target_link_libraries(
YK_PLAYER
# 写了此命令不用在意添加 ffmpeg lib 顺序问题致使应用崩溃
-Wl,--start-group
avcodec avfilter avformat avutil swresample swscale
-Wl,--end-group
z
rtmp
android
#音频播放
OpenSLES
${log_lib}
)
复制代码
定义 native 函数
/** * 当前 ffmpeg 版本 */
public native String getFFmpegVersion();
/** * 设置 surface * @param surface */
public native void setSurfaceNative(Surface surface);
/** * 作一些准备工做 * @param mDataSource 播放气质 */
public native void prepareNative(String mDataSource);
/** * 准备工做完成,开始播放 */
public native void startNative();
/** * 若是点击中止播放,那么就调用该函数进行恢复播放 */
public native void restartNative();
/** * 中止播放 */
public native void stopNative();
/** * 释放资源 */
public native void releaseNative();
/** * 是否正在播放 * @return */
public native boolean isPlayerNative();
复制代码
根据以前咱们的流程图得知在调用设置数据源了以后,ffmpeg 就开始解封装 (能够理解为收到快递包裹,咱们须要把包裹打开看看里面是什么,而后拿出来进行归类放置),这里就是把一个数据源分解成通过编码的音频数据、视频数据、字幕等,下面经过 FFmpeg API 来进行分解数据,代码以下:
/** * 该函数是真正的解封装,是在子线程开启并调用的。 */
void YKPlayer::prepare_() {
LOGD("第一步 打开流媒体地址");
//1. 打开流媒体地址(文件路径、直播地址)
// 能够初始为NULL,若是初始为NULL,当执行avformat_open_input函数时,内部会自动申请avformat_alloc_context,这里干脆手动申请
// 封装了媒体流的格式信息
formatContext = avformat_alloc_context();
//字典: 键值对
AVDictionary *dictionary = 0;
av_dict_set(&dictionary, "timeout", "5000000", 0);//单位是微妙
/** * * @param AVFormatContext: 传入一个 format 上下文是一个二级指针 * @param const char *url: 播放源 * @param ff_const59 AVInputFormat *fmt: 输入的封住格式,通常让 ffmpeg 本身去检测,因此给了一个 0 * @param AVDictionary **options: 字典参数 */
int result = avformat_open_input(&formatContext, data_source, 0, &dictionary);
//result -13--> 没有读写权限
//result -99--> 第三个参数写 NULl
LOGD("avformat_open_input--> %d,%s", result, data_source);
//释放字典
av_dict_free(&dictionary);
if (result) {//0 on success true
// 你的文件路径,或,你的文件损坏了,须要告诉用户
// 把错误信息,告诉给Java层去(回调给Java)
if (pCallback) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CAN_NOT_OPEN_URL);
}
return;
}
//第二步 查找媒体中的音视频流的信息
LOGD("第二步 查找媒体中的音视频流的信息");
result = avformat_find_stream_info(formatContext, 0);
if (result < 0) {
if (pCallback) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CAN_NOT_FIND_STREAMS);
return;
}
}
//第三步 根据流信息,流的个数,循环查找,音频流 视频流
LOGD("第三步 根据流信息,流的个数,循环查找,音频流 视频流");
//nb_streams = 流的个数
for (int stream_index = 0; stream_index < formatContext->nb_streams; ++stream_index) {
//第四步 获取媒体流 音视频
LOGD("第四步 获取媒体流 音视频");
AVStream *stream = formatContext->streams[stream_index];
//第五步 从 stream 流中获取解码这段流的参数信息,区分究竟是 音频仍是视频
LOGD("第五步 从 stream 流中获取解码这段流的参数信息,区分究竟是 音频仍是视频");
AVCodecParameters *codecParameters = stream->codecpar;
//第六步 经过流的编解码参数中的编解码 ID ,来获取当前流的解码器
LOGD("第六步 经过流的编解码参数中的编解码 ID ,来获取当前流的解码器");
AVCodec *codec = avcodec_find_decoder(codecParameters->codec_id);
//有可能不支持当前解码
//找不到解码器,从新编译 ffmpeg --enable-librtmp
if (!codec) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_FIND_DECODER_FAIL);
return;
}
//第七步 经过拿到的解码器,获取解码器上下文
LOGD("第七步 经过拿到的解码器,获取解码器上下文");
AVCodecContext *codecContext = avcodec_alloc_context3(codec);
if (!codecContext) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
return;
}
//第八步 给解码器上下文 设置参数
LOGD("第八步 给解码器上下文 设置参数");
result = avcodec_parameters_to_context(codecContext, codecParameters);
if (result < 0) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
return;
}
//第九步 打开解码器
LOGD("第九步 打开解码器");
result = avcodec_open2(codecContext, codec, 0);
if (result) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_OPEN_DECODER_FAIL);
return;
}
//媒体流里面能够拿到时间基
AVRational baseTime = stream->time_base;
//第十步 从编码器参数中获取流类型 codec_type
LOGD("第十步 从编码器参数中获取流类型 codec_type");
if (codecParameters->codec_type == AVMEDIA_TYPE_AUDIO) {
audioChannel = new AudioChannel(stream_index, codecContext,baseTime);
} else if (codecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
//获取视频帧 fps
//平均帧率 == 时间基
AVRational frame_rate = stream->avg_frame_rate;
int fps_value = av_q2d(frame_rate);
videoChannel = new VideoChannel(stream_index, codecContext, baseTime, fps_value);
videoChannel->setRenderCallback(renderCallback);
}
}//end for
//第十一步 若是流中没有音视频数据
LOGD("第十一步 若是流中没有音视频数据");
if (!audioChannel && !videoChannel) {
pCallback->onErrorAction(THREAD_CHILD, FFMPEG_NOMEDIA);
return;
}
//第十二步 要么有音频 要么有视频 要么音视频都有
LOGD("第十二步 要么有音频 要么有视频 要么音视频都有");
// 准备完毕,通知Android上层开始播放
if (this->pCallback) {
pCallback->onPrepared(THREAD_CHILD);
}
}
复制代码
上面的注释我标注的很全面,这里咱们直接跳到第十步,咱们知道能够经过以下 codecParameters->codec_type
函数来进行判断数据属于什么类型,进行进行单独操做。
在解封装完成以后咱们把待解码的数据放入队列中,以下所示:
/** * 读包 、未解码、音频/视频 包 放入队列 */
void YKPlayer::start_() {
// 循环 读音视频包
while (isPlaying) {
if (isStop) {
av_usleep(2 * 1000);
continue;
}
LOGD("start_");
//内存泄漏点 1,解决方法 : 控制队列大小
if (videoChannel && videoChannel->videoPackages.queueSize() > 100) {
//休眠 等待队列中的数据被消费
av_usleep(10 * 1000);
continue;
}
//内存泄漏点 2 ,解决方案 控制队列大小
if (audioChannel && audioChannel->audioPackages.queueSize() > 100) {
//休眠 等待队列中的数据被消费
av_usleep(10 * 1000);
continue;
}
//AVPacket 多是音频 多是视频,没有解码的数据包
AVPacket *packet = av_packet_alloc();
//这一行执行完毕, packet 就有音视频数据了
int ret = av_read_frame(formatContext, packet);
/* if (ret != 0) { return; }*/
if (!ret) {
if (videoChannel && videoChannel->stream_index == packet->stream_index) {//视频包
//未解码的 视频数据包 加入队列
videoChannel->videoPackages.push(packet);
} else if (audioChannel && audioChannel->stream_index == packet->stream_index) {//语音包
//将语音包加入到队列中,以供解码使用
audioChannel->audioPackages.push(packet);
}
} else if (ret == AVERROR_EOF) { //表明读取完毕了
//TODO----
LOGD("拆包完成 %s", "读取完成了")
isPlaying = 0;
stop();
release();
break;
} else {
LOGD("拆包 %s", "读取失败")
break;//读取失败
}
}//end while
//最后释放的工做
isPlaying = 0;
isStop = false;
videoChannel->stop();
audioChannel->stop();
}
复制代码
经过上面源码咱们知道,经过 FFmpeg API av_packet_alloc();
拿到待解码的指针类型 AVPacket
而后放入对应的音视频队列中,等待解码。
上一步咱们知道,解封装完成以后把对应的数据放入了待解码的队列中,下一步咱们就从队列中拿到数据进行解码,以下图所示:
/** * 视频解码 */
void VideoChannel::video_decode() {
AVPacket *packet = 0;
while (isPlaying) {
if (isStop) {
//线程休眠 10s
av_usleep(2 * 1000);
continue;
}
//控制队列大小,避免生产快,消费满的状况
if (isPlaying && videoFrames.queueSize() > 100) {
// LOGE("视频队列中的 size :%d", videoFrames.queueSize());
//线程休眠等待队列中的数据被消费
av_usleep(10 * 1000);//10s
continue;
}
int ret = videoPackages.pop(packet);
//若是中止播放,跳出循环,出了循环,就要释放
if (!isPlaying) {
LOGD("isPlaying %d", isPlaying);
break;
}
if (!ret) {
continue;
}
//开始取待解码的视频数据包
ret = avcodec_send_packet(pContext, packet);
if (ret) {
LOGD("ret %d", ret);
break;//失败了
}
//释放 packet
releaseAVPacket(&packet);
//AVFrame 拿到解码后的原始数据包
AVFrame *frame = av_frame_alloc();
ret = avcodec_receive_frame(pContext, frame);
if (ret == AVERROR(EAGAIN)) {
//重新取
continue;
} else if (ret != 0) {
LOGD("ret %d", ret);
releaseAVFrame(&frame);//内存释放
break;
}
//解码后的视频数据 YUV,加入队列中
videoFrames.push(frame);
}
//出循环,释放
if (packet)
releaseAVPacket(&packet);
}
复制代码
经过上面代码咱们获得,主要把待解码的数据放入 avcodec_send_packet
中,而后经过 avcodec_receive_frame
函数来进行接收,最后解码完成的 YUV 数据又放入原始数据队列中,进行转换格式
在 Android 中并不能直接播放 YUV, 咱们须要把它转换成 RGB 的格式而后在调用本地 nativeWindow 或者 OpenGL ES 来进行渲染,下面就直接调用 FFmpeg API 来进行转换,代码以下所示:
void VideoChannel::video_player() {
//1. 原始视频数据 YUV ---> rgba
/** * sws_getContext(int srcW, int srcH, enum AVPixelFormat srcFormat, int dstW, int dstH, enum AVPixelFormat dstFormat, int flags, SwsFilter *srcFilter, SwsFilter *dstFilter, const double *param) */
SwsContext *swsContext = sws_getContext(pContext->width, pContext->height,
pContext->pix_fmt,
pContext->width, pContext->height, AV_PIX_FMT_RGBA,
SWS_BILINEAR, NULL, NULL, NULL);
//2. 给 dst_data 申请内存
uint8_t *dst_data[4];
int dst_linesize[4];
AVFrame *frame = 0;
/** * pointers[4]:保存图像通道的地址。若是是RGB,则前三个指针分别指向R,G,B的内存地址。第四个指针保留不用 * linesizes[4]:保存图像每一个通道的内存对齐的步长,即一行的对齐内存的宽度,此值大小等于图像宽度。 * w: 要申请内存的图像宽度。 * h: 要申请内存的图像高度。 * pix_fmt: 要申请内存的图像的像素格式。 * align: 用于内存对齐的值。 * 返回值:所申请的内存空间的总大小。若是是负值,表示申请失败。 */
int ret = av_image_alloc(dst_data, dst_linesize, pContext->width, pContext->height,
AV_PIX_FMT_RGBA, 1);
if (ret < 0) {
printf("Could not allocate source image\n");
return;
}
//3. YUV -> rgba 格式转换 一帧一帧的转换
while (isPlaying) {
if (isStop) {
//线程休眠 10s
av_usleep(2 * 1000);
continue;
}
int ret = videoFrames.pop(frame);
//若是中止播放,跳出循环,须要释放
if (!isPlaying) {
break;
}
if (!ret) {
continue;
}
//真正转换的函数,dst_data 是 rgba 格式的数据
sws_scale(swsContext, frame->data, frame->linesize, 0, pContext->height, dst_data,
dst_linesize);
//开始渲染,显示屏幕上
//渲染一帧图像(宽、高、数据)
renderCallback(dst_data[0], pContext->width, pContext->height, dst_linesize[0]);
releaseAVFrame(&frame);//渲染完了,frame 释放。
}
releaseAVFrame(&frame);//渲染完了,frame 释放。
//中止播放 flag
isPlaying = 0;
av_freep(&dst_data[0]);
sws_freeContext(swsContext);
}
复制代码
上面代码就是直接经过 sws_scale
该函数来进行 YUV -> RGBA 转换。
转换完以后,咱们直接调用 ANativeWindow 来进行渲染,代码以下所示:
/** * 设置播放 surface */
extern "C"
JNIEXPORT void JNICALL Java_com_devyk_player_1common_PlayerManager_setSurfaceNative(JNIEnv *env, jclass type, jobject surface) {
LOGD("Java_com_devyk_player_1common_PlayerManager_setSurfaceNative");
pthread_mutex_lock(&mutex);
if (nativeWindow) {
ANativeWindow_release(nativeWindow);
nativeWindow = 0;
}
//建立新的窗口用于视频显示窗口
nativeWindow = ANativeWindow_fromSurface(env, surface);
pthread_mutex_unlock(&mutex);
}
复制代码
渲染:
/** * * 专门渲染的函数 * @param src_data 解码后的视频 rgba 数据 * @param width 视频宽 * @param height 视频高 * @param src_size 行数 size 相关信息 * */
void renderFrame(uint8_t *src_data, int width, int height, int src_size) {
pthread_mutex_lock(&mutex);
if (!nativeWindow) {
pthread_mutex_unlock(&mutex);
}
//设置窗口属性
ANativeWindow_setBuffersGeometry(nativeWindow, width, height, WINDOW_FORMAT_RGBA_8888);
ANativeWindow_Buffer window_buffer;
if (ANativeWindow_lock(nativeWindow, &window_buffer, 0)) {
ANativeWindow_release(nativeWindow);
nativeWindow = 0;
pthread_mutex_unlock(&mutex);
return;
}
//填数据到 buffer,其实就是修改数据
uint8_t *dst_data = static_cast<uint8_t *>(window_buffer.bits);
int lineSize = window_buffer.stride * 4;//RGBA
//下面就是逐行 copy 了。
//一行 copy
for (int i = 0; i < window_buffer.height; ++i) {
memcpy(dst_data + i * lineSize, src_data + i * src_size, lineSize);
}
ANativeWindow_unlockAndPost(nativeWindow);
pthread_mutex_unlock(&mutex);
}
复制代码
视频渲染就完成了。
音频的流程跟视频同样,拿到解封装以后的 AAC 数据开始进行解码,代码以下所示:
/** * 音频解码 */
void AudioChannel::audio_decode() {
//待解码的 packet
AVPacket *avPacket = 0;
//只要正在播放,就循环取数据
while (isPlaying) {
if (isStop) {
//线程休眠 10s
av_usleep(2 * 1000);
continue;
}
//这里有一个 bug,若是生产快,消费慢,就会形成队列数据过多容易形成 OOM,
//解决办法:控制队列大小
if (isPlaying && audioFrames.queueSize() > 100) {
// LOGE("音频队列中的 size :%d", audioFrames.queueSize());
//线程休眠 10s
av_usleep(10 * 1000);
continue;
}
//能够正常取出
int ret = audioPackages.pop(avPacket);
//条件判断是否能够继续
if (!ret) continue;
if (!isPlaying) break;
//待解码的数据发送到解码器中
ret = avcodec_send_packet(pContext,
avPacket);//@return 0 on success, otherwise negative error code:
if (ret)break;//给解码器发送失败了
//发送成功,释放 packet
releaseAVPacket(&avPacket);
//拿到解码后的原始数据包
AVFrame *avFrame = av_frame_alloc();
//将原始数据发送到 avFrame 内存中去
ret = avcodec_receive_frame(pContext, avFrame);//0:success, a frame was returned
if (ret == AVERROR(EAGAIN)) {
continue;//获取失败,继续下次任务
} else if (ret != 0) {//说明失败了
releaseAVFrame(&avFrame);//释放申请的内存
break;
}
//将获取到的原始数据放入队列中,也就是解码后的原始数据
audioFrames.push(avFrame);
}
//释放packet
if (avPacket)
releaseAVPacket(&avPacket);
}
复制代码
音视频的逻辑都是同样的就不在多说了。
渲染 PCM 可使用 Java 层的 AudioTrack ,也可使用 NDK 的 OpenSL ES 来渲染,我这里为了性能和更好的对接,直接都在 C++ 中实现了,代码以下:
/** * 音频播放 //直接使用 OpenLS ES 渲染 PCM 数据 */
void AudioChannel::audio_player() {
//TODO 1. 建立引擎并获取引擎接口
// 1.1建立引擎对象:SLObjectItf engineObject
SLresult result = slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 1.2 初始化引擎
result = (*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);
if (SL_BOOLEAN_FALSE != result) {
return;
}
// 1.3 获取引擎接口 SLEngineItf engineInterface
result = (*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineInterface);
if (SL_RESULT_SUCCESS != result) {
return;
}
//TODO 2. 设置混音器
// 2.1 建立混音器:SLObjectItf outputMixObject
result = (*engineInterface)->CreateOutputMix(engineInterface, &outputMixObject, 0, 0, 0);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 2.2 初始化 混音器
result = (*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);
if (SL_BOOLEAN_FALSE != result) {
return;
}
// 不启用混响能够不用获取混音器接口
// 得到混音器接口
// result = (*outputMixObject)->GetInterface(outputMixObject, SL_IID_ENVIRONMENTALREVERB,
// &outputMixEnvironmentalReverb);
// if (SL_RESULT_SUCCESS == result) {
// 设置混响 : 默认。
// SL_I3DL2_ENVIRONMENT_PRESET_ROOM: 室内
// SL_I3DL2_ENVIRONMENT_PRESET_AUDITORIUM : 礼堂 等
// const SLEnvironmentalReverbSettings settings = SL_I3DL2_ENVIRONMENT_PRESET_DEFAULT;
// (*outputMixEnvironmentalReverb)->SetEnvironmentalReverbProperties(
// outputMixEnvironmentalReverb, &settings);
// }
//TODO 3. 建立播放器
// 3.1 配置输入声音信息
// 建立buffer缓冲类型的队列 2个队列
SLDataLocator_AndroidSimpleBufferQueue loc_bufq = {SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE,
2};
// pcm数据格式
// SL_DATAFORMAT_PCM:数据格式为pcm格式
// 2:双声道
// SL_SAMPLINGRATE_44_1:采样率为44100(44.1赫兹 应用最广的,兼容性最好的)
// SL_PCMSAMPLEFORMAT_FIXED_16:采样格式为16bit (16位)(2个字节)
// SL_PCMSAMPLEFORMAT_FIXED_16:数据大小为16bit (16位)(2个字节)
// SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT:左右声道(双声道) (双声道 立体声的效果)
// SL_BYTEORDER_LITTLEENDIAN:小端模式
SLDataFormat_PCM format_pcm = {SL_DATAFORMAT_PCM, 2, SL_SAMPLINGRATE_44_1,
SL_PCMSAMPLEFORMAT_FIXED_16,
SL_PCMSAMPLEFORMAT_FIXED_16,
SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT,
SL_BYTEORDER_LITTLEENDIAN};
// 数据源 将上述配置信息放到这个数据源中
SLDataSource audioSrc = {&loc_bufq, &format_pcm};
// 3.2 配置音轨(输出)
// 设置混音器
SLDataLocator_OutputMix loc_outmix = {SL_DATALOCATOR_OUTPUTMIX, outputMixObject};
SLDataSink audioSnk = {&loc_outmix, NULL};
// 须要的接口 操做队列的接口
const SLInterfaceID ids[1] = {SL_IID_BUFFERQUEUE};
const SLboolean req[1] = {SL_BOOLEAN_TRUE};
// 3.3 建立播放器
result = (*engineInterface)->CreateAudioPlayer(engineInterface, &bqPlayerObject, &audioSrc,
&audioSnk, 1, ids, req);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 3.4 初始化播放器:SLObjectItf bqPlayerObject
result = (*bqPlayerObject)->Realize(bqPlayerObject, SL_BOOLEAN_FALSE);
if (SL_RESULT_SUCCESS != result) {
return;
}
// 3.5 获取播放器接口:SLPlayItf bqPlayerPlay
result = (*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_PLAY, &bqPlayerPlay);
if (SL_RESULT_SUCCESS != result) {
return;
}
//TODO 4. 设置播放器回调函数
// 4.1 获取播放器队列接口:SLAndroidSimpleBufferQueueItf bqPlayerBufferQueue
(*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_BUFFERQUEUE, &bqPlayerBufferQueue);
// 4.2 设置回调 void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *context)
(*bqPlayerBufferQueue)->RegisterCallback(bqPlayerBufferQueue, bqPlayerCallback, this);
//TODO 5. 设置播放状态
(*bqPlayerPlay)->SetPlayState(bqPlayerPlay, SL_PLAYSTATE_PLAYING);
//TODO 6. 手动激活回调函数
bqPlayerCallback(bqPlayerBufferQueue, this);
}
复制代码
设置渲染数据:
/** * 获取 PCM * @return */
int AudioChannel::getPCM() {
//定义 PCM 数据大小
int pcm_data_size = 0;
//原始数据包装类
AVFrame *pcmFrame = 0;
//循环取出
while (isPlaying) {
if (isStop) {
//线程休眠 10s
av_usleep(2 * 1000);
continue;
}
int ret = audioFrames.pop(pcmFrame);
if (!isPlaying)break;
if (!ret)continue;
//PCM 处理逻辑
pcmFrame->data;
// 音频播放器的数据格式是咱们在下面定义的(16位 双声道 ....)
// 而原始数据(是待播放的音频PCM数据)
// 因此,上面的两句话,没法统一,一个是(本身定义的16位 双声道 ..) 一个是原始数据,为了解决上面的问题,就须要重采样。
// 开始重采样
int dst_nb_samples = av_rescale_rnd(swr_get_delay(swr_ctx, pcmFrame->sample_rate) +
pcmFrame->nb_samples, out_sample_rate,
pcmFrame->sample_rate, AV_ROUND_UP);
//重采样
/** * * @param out_buffers 输出缓冲区,当PCM数据为Packed包装格式时,只有out[0]会填充有数据。 * @param dst_nb_samples 每一个通道可存储输出PCM数据的sample数量。 * @param pcmFrame->data 输入缓冲区,当PCM数据为Packed包装格式时,只有in[0]须要填充有数据。 * @param pcmFrame->nb_samples 输入PCM数据中每一个通道可用的sample数量。 * * @return 返回每一个通道输出的sample数量,发生错误的时候返回负数。 */
ret = swr_convert(swr_ctx, &out_buffers, dst_nb_samples, (const uint8_t **) pcmFrame->data,
pcmFrame->nb_samples);//返回每一个通道输出的sample数量,发生错误的时候返回负数。
if (ret < 0) {
fprintf(stderr, "Error while converting\n");
}
pcm_data_size = ret * out_sample_size * out_channels;
//用于音视频同步
audio_time = pcmFrame->best_effort_timestamp * av_q2d(this->base_time);
break;
}
//渲染完成释放资源
releaseAVFrame(&pcmFrame);
return pcm_data_size;
}
/** * 建立播放音频的回调函数 */
void bqPlayerCallback(SLAndroidSimpleBufferQueueItf bq, void *context) {
AudioChannel *audioChannel = static_cast<AudioChannel *>(context);
//获取 PCM 音频裸流
int pcmSize = audioChannel->getPCM();
if (!pcmSize)return;
(*bq)->Enqueue(bq, audioChannel->out_buffers, pcmSize);
}
复制代码
代码编写到这里,音视频也都正常渲染了,可是这里还有一个问题,随着播放的时间越久那么就会产生音视频各渲染各的,没有达到同步或者一直播放,这样的体验是很是很差的,因此下一小节咱们来解决这个问题。
音视频同步市面上有 3 种解决方案: 音频向视频同步,视频向音频同步,音视频统一贯外部时钟同步。下面就分别来介绍这三种对齐方式是如何实现的,以及各自的优缺点。
音频向视频同步
先来看一下这种同步方式是如何实现的,音频向视频同步,顾名思义,就是视频会维持必定的刷新频率,或者根据渲染视频帧的时长来决定当前视频帧的渲染时长,或者说视频的每一帧确定能够所有渲染出来,当咱们向 AudioChannel 模块填充音频数据的时候,会与当前渲染的视频帧的时间戳进行比较,这个差值若是不在阀值得范围内,就须要作对齐操做;若是其在阀值范围内,那么就能够直接将本帧音频帧填充到 AudioChannel 模块,进而让用户听到该声音。那若是不在阀值范围内,又该如何进行对齐操做呢?这就须要咱们去调整音频帧了,也就是说若是要填充的音频帧的时间戳比当前渲染的视频帧的时间戳小,那就须要进行跳帧操做(能够经过加快速度播放,也能够是丢弃一部分音频帧);若是音频帧的时间戳比当前渲染的视频帧的时间戳大,那么就须要等待,具体实现能够向 AudioChannel 模块填充空数据进行播放,也能够是将音频的速度放慢播放给用户听,而此时视频帧是继续一帧一帧进行渲染的,一旦视频的时间戳遇上了音频的时间戳,就能够将本帧的音频帧的数据填充到 AudioChannel 模块了,这就是音频向视频同步的实现。
优势: 视频能够将每一帧都播放给用户看,画面看上去是最流畅的。
缺点: 音频会加速或者丢帧,若是丢帧系数小,那么用户感知可能不太强,若是系数大,那么用户感知就会很是的强烈了,发生丢帧或者插入空数据的时候,用户的耳朵是能够明显感受到的。
视频向音频同步
再来看一下视频向音频同步的方式是如何实现的,这与上面提到的方式刚好相反,因为不管是哪个平台播放音频的引擎,均可以保证播放音频的时间长度与实际这段音频所表明的时间长度是一致的,因此咱们能够依赖于音频的顺序播放为咱们提供的时间戳,当客户端代码请求发送视频帧的时候,会先计算出当前视频队列头部的视频帧元素的时间戳与当前音频播放帧的时间戳的差值。若是在阀值范围内,就能够渲染这一帧视频帧;若是不在阀值范围内,则要进行对齐操做。具体的对齐操做方法就是: 若是当前队列头部的视频帧的时间戳小于当前播放音频帧的时间戳,那么就进行跳帧操做;若是大于当前播放音频帧的时间戳,那么就等待(睡眠、重复渲染、不渲染)的操做。
优势 : 音频能够连续的渲染。
缺点 : 视频画面会有跳帧的操做,可是对于视频画面的丢帧和跳帧用户的眼睛是不太容易分辨得出来的。
音视频统一贯外部时钟同步
这种策略其实更像是上述两种方式对齐的合体,其实现就是在外部单独维护一轨外部时钟,咱们要保证该外部时钟的更新是按照时间的增长而慢慢增长的,当咱们获取音频数据和视频帧的时候,都须要与这个外部时钟进行对齐,若是没有超过阀值,那么就会直接返回本帧音频帧或者视频帧,若是超过阀值就要进行对齐操做,具体的对齐操做是: 使用上述两种方式里面的对齐操做,将其分别应用于音频的对齐和视频的对齐。
优势: 能够最大限度的保证音视频均可以不发生跳帧的行为。
缺点: 外部时钟很差控制,极有可能引起音频和视频都跳帧的行为。
同步总结:
根据人眼睛和耳朵的生理构造因素,得出了一个结论,那就是人的耳朵比人的眼睛要敏感的多,那就是说,若是音频有跳帧的行为或者填空数据的行为,那么咱们的耳朵是很是容易察以为到的;而视频若是有跳帧或者重复渲染的行为,咱们的眼睛其实不容易分别出来。根据这个理论,因此咱们这里也将采用 视频向音频对齐 的方式。
根据得出的结论,咱们须要在音频、视频渲染以前修改几处地方,以下所示:
经过 ffmpeg api 拿到音频时间戳
//1. 在 BaseChannel 里面定义变量,供子类使用
//###############下面是音视频同步须要用到的
//FFmpeg 时间基: 内部时间
AVRational base_time;
double audio_time;
double video_time;
//###############下面是音视频同步须要用到的
//2. 获得音频时间戳 pcmFrame 解码以后的原始数据帧
audio_time = pcmFrame->best_effort_timestamp * av_q2d(this->base_time);
复制代码
视频向音频时间戳对齐(大于小于音频时间戳的处理方式)
//视频向音频时间戳对齐---》控制视频播放速度
//在视频渲染以前,根据 fps 来控制视频帧
//frame->repeat_pict = 当解码时,这张图片须要要延迟多久显示
double extra_delay = frame->repeat_pict;
//根据 fps 获得延迟时间
double base_delay = 1.0 / this->fpsValue;
//获得当前帧的延迟时间
double result_delay = extra_delay + base_delay;
//拿到视频播放的时间基
video_time = frame->best_effort_timestamp * av_q2d(this->base_time);
//拿到音频播放的时间基
double_t audioTime = this->audio_time;
//计算音频和视频的差值
double av_time_diff = video_time - audioTime;
//说明:
//video_time > audioTime 说明视频快,音频慢,等待音频
//video_time < audioTime 说明视频慢,音屏快,须要追赶音频,丢弃掉冗余的视频包也就是丢帧
if (av_time_diff > 0) {
//经过睡眠的方式灵活等待
if (av_time_diff > 1) {
av_usleep((result_delay * 2) * 1000000);
LOGE("av_time_diff > 1 睡眠:%d", (result_delay * 2) * 1000000);
} else {//说明相差不大
av_usleep((av_time_diff + result_delay) * 1000000);
LOGE("av_time_diff < 1 睡眠:%d", (av_time_diff + result_delay) * 1000000);
}
} else {
if (av_time_diff < 0) {
LOGE("av_time_diff < 0 丢包处理:%f", av_time_diff);
//视频丢包处理
this->videoFrames.deleteVideoFrame();
continue;
} else {
//完美
}
}
复制代码
加上这段代码以后,我们音视频就算是差很少同步了,不敢保证 100%。
一个简易的音视频播放器已经实现完毕,我们从解封装->解码->音视频同步->音视频渲染
按照流程讲解并编写了实例代码,相信你已经对播放器的流程和架构设计都已经有了必定的认识,等公司有需求的时候也能够本身设计一款播放器并开发出来了。