1. 程式人生 > >Android 音視訊採集與軟編碼總結

Android 音視訊採集與軟編碼總結

前言

本文總結了筆者在 Android 音視訊採集與軟編碼中的一些經驗與技巧,包括移植 FFmpegYUV 視訊幀處理、最新的 JNI 編寫技巧、 ndk 開發技巧等,為了不扯太遠本文不會對音視訊編碼的一些原理性東西進行剖析,也不會大量貼原始碼,更注重使用方法與流程的講解。 文章最後將展示一個實現了音視訊採集功能與本地視訊壓縮功能的完整專案。

採用軟編碼利弊

眾所周知, Android API 中有個 MediaCodec 的類,利用 MediaCodec 可實現硬編碼,高效且方便,Android API 中還有個叫 MediaMuxer 的類,可以實現音訊與視訊的合成,但是 MediaCodec

 使用的 API 最低要求是 16,MediaMuxer 則是 18,除此之外如果對視訊想做更多的處理還需要做比較多的開發,當然這點純粹是偷懶。如果使用軟編碼,那麼 API 限制將不再那麼苛刻,筆者開發時調到了 14,然後由於我用的是 FFmpeg 且移植了它的命令列工具,那麼對視訊很多的處理操作可以使用簡單命令即可完成。視訊編碼我為 FFmpeg 配置的是 x264 ,其可編碼為 H.264 視訊格式,除此之外比較出名的還有下一代編碼標準 HEVCVP9。音訊我為 FFmpeg 配置的是 libfdk-aac ,其 libfaac 在新版 FFmpeg 中已經被拋棄了。x264 現在在演算法上基本達到了瓶頸,而軟編效率很大程度上依賴演算法與 cpu
 ,所以 cpu 就成了決定效率的關鍵,經過我實驗,雖然其跟硬編碼效率還是有差距,但是它在普通 64 位的 cpu 上表現還是不錯的,處理 480P、幀率 30 、位元率 1000000 的視訊幀基本沒有延遲。

本地依賴庫的準備

需要準備的依賴庫有 FFmpeglibx264libfdk-aac 等,它們都是不能直接在 Android 上使用的,需要修改些東西並且需要用 NDK 編譯成動態連結庫或者靜態連結庫,修改的東西其實主要是一些命名上的東西,很多平臺識別的格式不太一樣,如 Android 上不能識別 ffmpeg.so.01 這樣的庫,對於 FFmpeg 的編譯網上有非常多的指令碼,但是如果你需求不是和他一模一樣,建議還是自己定製下,其實定製無非是對一些功能的開開關關而已,其他平臺可能無所謂,大不了功能全開唄,但是對於 Android

 來說,編譯個全功能的 FFmpeg 可不是什麼好事,apk 將會隨之變大很多,這是不能接受的。這裡有一個專案裡面包含了 FFmpeglibx264libfdk-aac 全平臺的編譯指令碼與 Android 下使用的簡單 Demo,https://github.com/mabeijianxi/FFmpeg4Android ,你只需要簡單修改指令碼即可定製自己的 FFmpeg 了。

CMake 指令碼編寫

如果你 Android Studio 版本大於 2.2 ,且在新建專案時如果勾選上了 Incude C++ Support ,那麼 AS 將會預設使用 CMake 外掛,我們一般情況下只需要對 CMakeLists.txt進行編寫即可完成 Native 編譯。下面展示一段 CMakeLists.txt編寫的實用列子:

cmake_minimum_required(VERSION 3.4.1)

add_library( # Sets the name of the library.
             jxffmpegrun

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
              src/main/cpp/cmdutils.c
              src/main/cpp/ffmpeg.c
              src/main/cpp/ffmpeg_filter.c
              src/main/cpp/ffmpeg_opt.c
              src/main/cpp/jx_ffmpeg_cmd_run.c
             )
add_library(
            avcodec
            SHARED
            IMPORTED
            )

if(${ANDROID_ABI} STREQUAL "armeabi")
set_target_properties(
    avcodec
    PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi/libavcodec.so
    )
endif(${ANDROID_ABI} STREQUAL "armeabi")

if(${ANDROID_ABI} STREQUAL "armeabi-v7a")
set_target_properties(
    avcodec
    PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi-v7a/libavcodec.so
    )
endif(${ANDROID_ABI} STREQUAL "armeabi-v7a")

if(${ANDROID_ABI} STREQUAL "arm64-v8a")
set_target_properties(
    avcodec
    PROPERTIES IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/src/main/jniLibs/arm64-v8a/libavcodec.so
    )
endif(${ANDROID_ABI} STREQUAL "arm64-v8a")

include_directories(
    ${CMAKE_SOURCE_DIR}/ffmpeg-3.2.5

)

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log )

target_link_libraries( # Specifies the target library.
                       jxffmpegrun
                       avcodec
                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

簡單分析下上面的指令碼:

  • cmake_minimum_required 命令的作用是指定 CMake 使用的最小版本。
  • add_library 命令的作用是新增一個庫,首先指定的是庫名,如 jxffmpegrun,根據規範最後會生成 libjxffmpegrun.so 或者 libjxffmpegrun.a ,指定連結庫型別,如SHARED 代表動態連結庫,STATIC 代表靜態連結庫,區別讀者可自行查詢其他資料。接著你可以新增 c/c++ 原始檔,也可以新增預編譯庫。如上面列子中第一個 add_library 裡條件了原始檔,而第二個添加了預編譯庫檔案。
  • 對於預編譯庫檔案由於架構不同不能直接新增,比如你在編譯 armeabi-v7a 架構時不可能去連結一個 arm64-v8a 的庫吧,所以我們需要做一些判斷,用簡單的 if() endif() 語句即可實現,如上面的 if(${ANDROID_ABI} STREQUAL "arm64-v8a") ....endif(${ANDROID_ABI} STREQUAL "arm64-v8a") 就表示在編譯 ABI 為 arm64-v8a 時就設定一個與之對應的 arm64-v8a 預構建庫,其他型別 ABI 也是如此操作。
  • set_target_properties 命令顧名思義就是為目標配置一些屬性,第一個引數是 target,比如我上面就指定了 avcodec 這個 library,然後 PROPERTIES 欄位後面跟上鍵值對,鍵是屬性,比如上面的 IMPORTED_LOCATION ,值是${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi/libavcodec.so ,加起來就表示為avcodec 這個library 設定了一個本地匯入的路徑。這個路徑必須是絕對路徑,不然編譯不過去的,然後為了可移植性,所以我在前面通過 ${CMAKE_SOURCE_DIR} 獲取了 CMake 指令碼目錄。
  • include_directories 命令則是指定標頭檔案的搜尋路徑。
  • find_library 命令用來定位 NDK 庫,並將其路徑儲存為一個變數,您可以使用此變數在構建指令碼的其他部分引用 NDK 庫,比如上面搜尋了一個 NDK 裡面自帶的 log庫。
  • target_link_libraries 命令指定要關聯到的原生庫,可以看到上面我們添加了 3 個庫。

上面只簡單介紹了常用的 CMakeLists.txt 編寫,更多可以查閱官方文件,我這裡貼出一個 CMake 3.4 的官方幫助文件地址 https://cmake.org/cmake/help/v3.4

視訊幀與音訊幀的採集工作

在 Android 上我們可以利用 Camera 類或者 camera2 來開啟攝像頭進行視訊幀的採集,當然 camera2 最低 API 需要 21,音訊可以用 AudioRecord 來開啟麥克風進行音訊幀的採集。

視訊幀採集:

對於 Android 開發者來說 Camera 這個類並不陌生,我們只需要一些簡單配置以後就可以配合 SurfaceView 來瀏覽攝像頭所捕捉到的畫面,我們這次的部分配置也許與往常的不同,因為我們需要自己處理每一幀視訊,比如需要設定一個取樣格式: Camera.Parameters.setPreviewFormat,點進原始碼你會發現裡面所支援的格式有很多,但是不幸的是 5.0 以前只支援 NV12 與 YV12 ,關於這兩種取樣模式等下會細講。除此之外你還需新增緩衝區來存放臨時的視訊資料,並設定取樣回撥,如:


這裡的 buffSize 大小與取樣格式息息相關,不過 NV12 與 YV12 的每幀大小倒是剛好一樣大的,都是3/2*H*W,在 TODO 的位置我們就可以對視訊進行編碼與或者其他操作,但是千萬別直接編碼或者操作,很關鍵!你可以使用一個佇列來儲存資料,然後開啟一個執行緒去讀這個佇列裡面的資料,然後進行操作,因為你直接操作很可能阻塞這個執行緒,這個執行緒是主執行緒,雖然一般不會導致 ANR ,但是很可能造成丟幀,比如你採集的幀率是 30fps ,採集到第一幀的時候你阻塞了一會兒,那麼很可能第二第三幀就會丟,然後你處理第四幀,第五第六幀就會丟,最後播放視訊的時候就會像按了快進一樣,並且處理完後我們需要及時把 buffer 歸還給 Camera

音訊幀採集:

這裡先貼出一段 AudioRecord 使用的程式碼殘片:



與 Camera 的配置相比,AudioRecord 簡單了很多,在例項化 AudioRecord 時需要指定採集源,筆者設定為了麥克風,然後指定取樣率,筆者採用相容性非常強的 44100Hz ,也就是每秒採集 44100 次,接著是配置音訊通道,由於筆者對音訊要求不是很高所以採用了 AudioFormat.CHANNEL_IN_MONO 代表單通道,當然也是支援雙通道立體聲採集的,只需傳入 AudioFormat.CHANNEL_IN_STEREO 即可,然後再設定取樣的資料格式,也就是每個取樣值所佔空間的大小,筆者選擇了 16位 也就是 2 byte ,最後再配置上緩衝器大小,這個值一般不是寫死的可以通過 AudioRecord.getMinBufferSize 來獲取一個最小值。
接著只需要呼叫 AudioRecord.read 即可獲取採集到的 PCM 視訊。

NV12 與 YV12

先簡要說說YUV格式,與RGB類似YUV也是一種顏色編碼方法,Y:表示明亮度(Luminance或Luma),也就是灰度值;而 U 和 V :表示的則是色度(Chrominance或Chroma),作用是描述影像色彩及飽和度,用於指定畫素的顏色。如果只有Y那麼就是黑白音像。根據取樣方式不同主要有YUV4:4:4,YUV4:2:2,YUV4:2:0。其YUV 4:4:4取樣,每一個Y對應一組UV分量。 YUV 4:2:2取樣,每兩個Y共用一組UV分量。YUV 4:2:0取樣,每四個Y共用一組UV分量 。舉個例子,螢幕上有八個畫素點,YUV4:4:4會有8個Y,8個U,8個V。YUV4:2:2會有8個Y,4個U,4個V。YUV4:2:0會有8個Y,2個U,2個V。
YV12與NV12,他們都是屬於YUV420,只是其排列結構不同(下圖是網上找的,原圖有錯,這裡 P 了一下)。

YV12:


可以看到Y1, Y2, Y7, Y8這些物理上相近的4個畫素公用了同樣的U1和V1,相似的Y3,Y4,Y9,Y10用的就是U2和V2。這裡不同的顏色把這個特性刻畫的非常形象,一 目瞭然。格子數目就是這一幀影象的byte陣列的大小,其陣列元素排放順序就是後面那一長條的樣子。

NV12:



可以發現與 YV12 相比它們只是UV的排放位置不同而已。

YUV 4:2:0

YV12 與 NV12 都屬於 YUV 420,一個畫素點由 Y、U、V三個通道組成,YUV420 對每個畫素的 Y都會掃描取樣。之所以叫YUV4:2:0,不是因為沒有V,它其實是在縱向上UV交換掃描的,比如第一行掃描U第二行就掃描V,第三行再掃描U。在橫向上是隔一個掃描,比如第一列掃描了,第二列就不掃描,然後掃描第三列。YV12 與 NV12 只是存放的資料結構不同而已。

一幀 YUV420 大小?

大小是 3/2*W*H ,width*height 個Y加上 (1/4)*width*height 個 V 加上(1/4)*width*height個 U。

YV12 最簡單的處理列子

用YV12於NV12都是可以的,筆者在配置相機引數的時候選擇了YV12,下面將展示視訊剪下與旋轉的處理方法,非常簡單,筆者也是想象著寫出來的。

這個手機框的目的只是為了展示攝像頭採集的視訊方向,不管你手機如何拿,採集的原始視訊都是橫的。這裡假設我們採集的視訊寬是 640,高是 480,我們要剪下成寬是 400,高是 300 的視訊。根據上面的知識我們能知道 640*480 的一幀 byte 數組裡面將會有 640*480 個Y,且排在最前面,然後有 (1/4)*640*480 個V,然後有(1/4)*640*480 個U,我們要剪下成400*300,自然是保留一部分資料即可。我們先對Y建立一個模型,既然是 640*480,我們可以把它當成一行有 640 個Y,一共有 480 行,如下圖所示紅色標註內表示 640*480 個Y,而黃色區域內則是我們剪下完成的Y的所有值。


需要注意影象方向哈。有了這個模型我們就可以寫程式碼運算元組了。下面搞段程式碼:

剪下Y:

        unsigned char *in_buf;
        unsigned char *out_buf_y;

        for(int i=480-300;i<480;i++){//遍歷高
            for(int j=0;j<400;j++){//遍歷寬
                int index=640*i+j;//當前遍歷到的角標
                unsigned char value=*(in_buf+index);//當前角標下的Y值

//             開始賦值給我們的目標陣列
                *(out_buf_y+(i-(480-300))*400+j)=value;//目標陣列是400*300的,這裡是從0角標開始依次全部遍歷且賦值
            }
        }

假設in_buf是一幀YV12視訊資料的話,執行完這個迴圈我們就得到剪下好的Y值了,接下來我們解析剪下UV資料,UV的模型和Y有點不同。之所以叫YUV4:2:0,不是因為沒有V,它其實是在縱向上UV交換掃描的,比如第一行掃描U第二行就掃描V,第三行再掃描U。在橫向上是隔一個掃描,比如第一列掃描了,第二列就不掃描,然後掃描第三列。所以U在橫向和縱向上的資料都是其Y的1/2,總數量是其1/4,V也是一樣的。知道了這些我們就可以輕易的建立模型。



320*240的區域就是我們就是我們U值或者V值的區域,200*150的區域就是我們剪下後的U值或者V值的目標區域。程式碼如下:

剪下UV:

unsigned char *in_buf;
        unsigned char *out_buf_u;
        unsigned char *out_buf_v;

        for(int i=(480-300)/2;i<480/2;i++){//遍歷高
            for(int j=0;j<400/2;j++){//遍歷寬

                int index=(640/2)*i+j;//當前遍歷到的角標
                unsigned char v=*(in_buf+(640*480)+index);//當前角標下的V值(指標位置得先向後移640*480個單位,因為前面放的是Y)

                unsigned char u=*(in_buf+(640*480*5/4)+index);//當前角標下的U值(指標位置得先向後移640*480*5/4個單位,因為前面放的是Y和V)

//              從0角標開始賦值給我們的目標陣列out_buf_u
                *(out_buf_u+(i-(480-300)/2)*400/2+j)=u;
                *(out_buf_v+(i-(480-300)/2)*400/2+j)=v;
            }
        }

經過上面的操作我們已經完成了最基本的剪下,攝像頭採集的資料是橫屏的,如果我們豎屏錄製且我們不做任何操作的話這時候我們錄製的視訊是逆時針旋轉了90°的,你逆時針那哥就順時針給你轉90°,這樣應該就正了。



思路有了,就是如上圖所示,我們for迴圈不變,因為需要剪下的位置不變,我們只改變輸出陣列的排放位置,原來第一排的放到最後一列,第二排放到倒數第二列,以此內推。下面也用程式碼演示下:

Y剪下並順時針旋轉90°:

unsigned char *in_buf;
            unsigned char *out_buf_y;

            for(int i=(480-300);i<480;i++){//遍歷高
                for(int j=0;j<400;j++){//遍歷寬

                    int index=(640)*i+j;//當前遍歷到的角標

                    unsigned char value=*(in_buf+index);//當前角標下的Y值

                    *(out_buf_y+j*300+(300-(i-(480-300)-1)))=value;//結合輸出陣列的影象即可明白
                }
            }

Y弄好了UV就特別簡單,因為我們已經掌握了規律,UV在橫向和縱向上的值都是Y的一半。

剪下UV:

            unsigned char *in_buf;
            unsigned char *out_buf_u;
            unsigned char *out_buf_v;

            for(int i=(480-300)/2;i<480/2;i++){//遍歷高
                for(int j=0;j<400/2;j++){//遍歷寬

                    int index=(640/2)*i+j;//當前遍歷到的角標

                    unsigned char value_v=*(in_buf+(640*480)+index);//當前角標下的V值
                    unsigned char value_u=*(in_buf+(640*480*5/4)+index);//當前角標下的U值

                    *(out_buf_u+j*300/2+(300/2-(i-(480-300)/2-1)))=value_u;//結合輸出陣列的影象即可明白
                    *(out_buf_v+j*300/2+(300/2-(i-(480-300)/2-1)))=value_v;//結合輸出陣列的影象即可明白
                }
            }

因為前置攝像頭的原因,會導致映象,所以在用前置攝像頭錄製的時候還需要處理映象,原理都差不多的,除了這些我們可以做好多有趣的操作,比如當UV值都賦予128的時候就成了黑白影像,你還可以調節亮度色調,也能做些美顏效果等等。

處理完資料後呼叫FFmpeg編碼的API即可。

Native 開發

JNI

如果開啟了 c++ 支援(前面有說)且啟用了 CMake 外掛(開啟了 c++ 支援後預設啟用),那在編寫 JNI 的時候基本就是秒秒鐘的事:



當你在 Java 層編寫了個 native 介面肯定是紅的,沒關係你只要按住 Alt + Enter 即可出現如圖提示,然後再重擊一下 Enter 鍵,就可在底層生成對應的 JNI 函數了:



這些細節可以減少我們一定的開發時間,而且感覺心裡比較爽。
編寫好了

JNI 呼叫 Java 方法的一些坑

JNI 呼叫 Java,網上的列子非常多,一般情況下普通呼叫沒什麼問題。但是有些情況下就會失敗,比如你開啟了一個 native 執行緒,線上程裡面用原始的方式去呼叫就會出錯,可能會報地址找不到等錯誤:



可以看到上面筆者是通過 native Debug 捕獲到的,除錯的時候非常有用。迴歸剛才的問題,我們看看官方的解釋:



沒錯筆者就是通過 pthread_create 函式建立了一個執行緒,所以在這執行緒裡面直接 FindClass 或者其他試圖呼叫 Java 方法的操作都不行,都無法與你當前應用程式相關聯, 官方給出了三種解決辦法,基本都是在 Java 執行緒中去快取一些東西,然後在 Native 執行緒取用,筆者總結了一個方案:

  1. Java 執行緒中把 JNI 函式裡面傳入的 jclass(靜態 native 方法時) 或者 jobject(非靜態 native 方法時)通過 env->NewGlobalRef 函式轉變為一個全域性引用存起來。
  2. 在 Java 執行緒中通過 env->GetJavaVM 獲取當前 javaVM 指標,並且存起來。
  3. 在 Native 執行緒需要呼叫 Java 方法時先通過 javaVM->AttachCurrentThread 把儲存的虛擬機器與當前執行緒繫結。
  4. 實現呼叫 Java 方法的邏輯。
  5. 解綁當前執行緒防止洩露,javaVM->DetachCurrentThread

下面通過簡單程式碼直觀的展示下:

typedef struct Arguments {
    JavaVM *javaVM; //jvm指標
    jclass java_class; //java介面類的calss物件
} ;

JNIEXPORT jint JNICALL
Java_com_mabeijianxi_smallvideorecord2_jniinterface_FFmpegBridge_test(JNIEnv *env, jclass type) {

    Arguments *arguments = (Arguments *) malloc(sizeof(Arguments));
//   step 1
     arguments->java_class = (jclass) env->NewGlobalRef(type);
//   step 2
     env->GetJavaVM(&arguments->javaVM);

    pthread_t thread;
    pthread_create(&thread, NULL,start_native_thread , arguments);
    return 0;
}

void * start_native_thread(Arguments * arguments){

    JNIEnv *env;
//    step 3
    arguments->javaVM->AttachCurrentThread(&env, NULL);

//    step 4
    jmethodID pID = env->GetStaticMethodID(arguments->java_class, "notifyState", "(IF)V");
    env->CallStaticVoidMethod(arguments->java_class, pID, END_STATE, 0);
//    step 5 
    arguments->javaVM->DetachCurrentThread();
}

為了簡介,上述程式碼簡化了異常處理的部分。

NDK

在 NDK 開發之前,你可以選擇使用的標準庫,預設是 Toolchain Default ,也就是 Toolchain 中預設所包含的庫,也可以選擇 C++ 11:



筆者在開發過程中一開始使用的是 Toolchain 中預設的庫,後來發現很多實用的庫都找不到,於是後來改用了 C++ 11,修改方法也很簡單,只需要在 gradle.build 指令碼中新增 cppFlags 引數即可:



在 cppFlags 裡面除了還可以加非常多的配置,比如我們經常定義一個巨集,用來控制日誌的開關:



在程式碼裡面我們可以通過 #ifdef Debug TODO.. # endif 語句來做些事情。
在開發過程中需要注意記憶體的回收,開發完成後可以用 Android Monitor 檢查下 :



如果開發的時候又依賴與其他預構建動態連結庫,在 Java 中 loadLibrary 時需要注意順序,一般手機都是沒問題的,但是 Android 百家爭鳴,部分手機如果你不順序載入將會 crash,順序載入的意思其實就是比如 a.so 裡面用 b.so, b.so 裡面又用了 c.so ,那麼載入順序為 c->b->a。

Native 除錯

和 Java 層一樣,要麼斷點,要麼日誌,日誌方法歷史悠久,現在來看看 Debug 斷點。只需要開啟一個工具 LLDB:



在需要調式的時候點選 Run->Aattach debugger Android to process->Auto/Native 即可:



需要注意,不需 Native 除錯的時候選擇 Java ,不然非常慢...。

完整案例介紹

上面介紹了案例中的部分實現與原理進行了闡述,下面介紹一個完整的案例,先看 2.x demo GIF 圖:


這個工程包含兩個專案,一個是 1.x 版本,一個是 2.x 版本,1.x 版本程式碼包括 SO 庫在內,大部分來自 Vitamio 的一個免費專案,其 Java 層是開源的,但歷史悠久筆者也只能維護 Java 層程式碼,或者利用 FFmpeg 介面實現更多功能,於是後來筆者便重寫了一套底層程式碼,2.x 就產生了,下面對比下目前兩版本的功能:

1.x 2.x
執行 FFmpeg 命令
自定義 FFmpeg ×
自定義各種錄製尺寸
錄製幀率控制
錄製位元率控制
本地視訊壓縮
本地壓縮位元速率模式選擇
錄製位元速率模式選擇 ×
本地視訊縮放壓縮