如今一個 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%。
一個簡易的音視頻播放器已經實現完畢,我們從解封裝->解碼->音視頻同步->音視頻渲染
按照流程講解並編寫了實例代碼,相信你已經對播放器的流程和架構設計都已經有了必定的認識,等公司有需求的時候也能夠本身設計一款播放器並開發出來了。