音視訊學習 (十一) Android 端實現 rtmp 推流

DevYK發表於2020-03-02

前言

我們們回顧前面 2 篇文章,主要講解了如何搭建 rtmp 直播伺服器,和如何開發一款具有拉流功能的 Android 播放器。那麼現在有了播放端和直播伺服器還缺少推流端。該篇文章我們就一起來實現 Android 端的 rtmp 推流,想要實現 Android 端推流必須要經過如下幾個階段,見下圖:

音視訊學習 (十一) Android 端實現 rtmp 推流

該篇文章主要完成上圖黃顏色功能部分,下面就開始進入正題,程式碼編寫了。

專案效果

推流監控

音視訊學習 (十一) Android 端實現 rtmp 推流

軟編碼

音視訊學習 (十一) Android 端實現 rtmp 推流

硬編碼

音視訊學習 (十一) Android 端實現 rtmp 推流

文章末尾會介紹軟硬編解碼。

音訊採集

Android SDK 提供了兩套音訊採集的 API ,分別是 MediaRecorder 、AudioRecord 。前者是一個上層 API ,它可以直接對手機麥克風錄入的音訊資料進行編碼壓縮(如 AMR/MP3) 等,並儲存為檔案;後者則更接近底層,能夠更加自由靈活地控制,其可以讓開發者得到記憶體中的 PCM 原始音訊資料流。如果想做一個簡單的錄音機,輸出音訊檔案則推薦使用 MediaRecorder ; 如果需要對音訊做進一步的演算法處理,或者需要採用第三方的編碼庫進行編碼,又或者需要用到網路傳輸等場景中,那麼只能使用 AudioRecord 或者 OpenSL ES ,其實 MediaRecorder 底層也是呼叫了 AudioRecord 與 Android Framework 層的 AudioFlinger 進行互動的。而我們該篇的場景更傾向於第二種實現方式,即使用 AudioRecord 來採集音訊。

如果想要使用 AudioRecord 這個 API ,則需要在應用 AndroidManifest.xml 的配置檔案中進行如下配置:

 <uses-permission android:name="android.permission.RECORD_AUDIO"></uses-permission>
複製程式碼

當然,如果你想把採集到的 PCM 原始資料,儲存 sdcard 中,還需要額外新增寫入許可權:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
複製程式碼

接下來了解一下 AudioRecord 的工作流程。

1. 初始化 AudioRecord

首先來看一下 AudioRecord 的配置引數,AudioRecord 是通過建構函式來配置引數的,其函式原型如下:

public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,int bufferSizeInBytes)
複製程式碼

上述引數所代表的函式及其在各種場景下應該傳遞的值的含義參考如下說明:

audioSource: 該引數指的是音訊採集的輸入源,可選值以常量的形式定義在類 AudioSource (MediaRecorder 中的一個內部類)中,常用的值包過:

  • DEFAULT(預設)
  • VOICE_RECOGNITION (用於語音識別,等同於預設)
  • MIC (由手機麥克風輸入)
  • VOICE_COMMUNICATION (用於 VOIP 應用場景)

sampleRateInHz: 用於指定以多大的取樣頻率來採集音訊,現在用的最多的相容最好是 44100 (44.1KHZ)取樣頻率。

channelConfig: 該引數用於指定錄音器採集幾個聲道的聲音,可選值以常量的形式定義在 AudioFormat 類中,常用的值包括:

  • CHANNEL_IN_MONO 單聲道 (移動裝置上目前推薦使用)
  • CHANNEL_IN_STEREO 立體聲

audioFormat: 取樣格式,以常量的形式定義在 AudioFormat 類中,常用的值包括:

  • ENCODING_PCM_16BIT (16bit 相容大部分 Android 手機)
  • ENCODING_PCM_8BIT (8bit)

bufferSizeInBytes: 配置內部音訊緩衝區的大小(配置的快取值越小,延時就越低),而具體的大小,有可能在不同的手機上會有不同的值,那麼可以使用如下 API 進行確定緩衝大小:

AudioRecord.getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat);
複製程式碼

配置好之後,檢查一下 AudioRecord 當前的狀態是否可以進行錄製,可以通過 AudioRecord##getState 來獲取當前的狀態:

  • STATE_UNINITIALIZED 還沒有初始化,或者初始化失敗了
  • STATE_INITIALIZED 已經初始化成功了。

2. 開啟採集

建立好 AudioRecord 之後,就可以開啟音訊資料的採集了,可以通過呼叫下面的函式進行控制麥克風的採集:

mAudioRecord.startRecording();
複製程式碼

3. 提取資料

執行完上一步之後,需要開啟一個子執行緒用於不斷的從 AudioRecord 緩衝區讀取 PCM 資料,呼叫如下函式進行讀取資料:

int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes);
複製程式碼

4. 停止採集

如果想要停止採集,那麼只需要呼叫 AudioRecord 的 stop 方法來實現,最後可以通過一個變數先控制子執行緒停止讀取資料,然後在呼叫 stop 停止最後釋放 AudioRecord 例項。

    public void stopEncode() {
      	//停止的變數標記
        mStopFlag = true;
        if(mAudioEncoder != null) {
          	//停止採集
            mAudioEncoder.stop();
          	//釋放記憶體
            mAudioEncoder = null;
        }
    }
複製程式碼

視訊採集

視訊畫面的採集主要是使用各個平臺提供的攝像頭 API 來實現的,在為攝像頭設定了合適的引數之後,將攝像頭實時採集的視訊幀渲染到螢幕上提供給使用者預覽,然後將該視訊幀傳遞給編碼通道,進行編碼。

1. 許可權配置

<uses-permission android:name="android.permission.CAMERA"></uses-permission>
複製程式碼

2. 開啟攝像頭

2.1 檢查攝像頭

public static void checkCameraService(Context context)
            throws CameraDisabledException {
    // Check if device policy has disabled the camera.
    DevicePolicyManager dpm = (DevicePolicyManager) context.getSystemService(
            Context.DEVICE_POLICY_SERVICE);
    if (dpm.getCameraDisabled(null)) {
        throw new CameraDisabledException();
    }
}
複製程式碼

2.2 檢查攝像頭的個數

檢查完攝像頭服務後,還需要檢查手機上攝像頭的個數,如果個數為 0,則說明手機上沒有攝像頭,這樣的話也是不能進行後續操作的。

public static List<CameraData> getAllCamerasData(boolean isBackFirst) {
    ArrayList<CameraData> cameraDatas = new ArrayList<>();
    Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
    int numberOfCameras = Camera.getNumberOfCameras();
    for (int i = 0; i < numberOfCameras; i++) {
        Camera.getCameraInfo(i, cameraInfo);
        if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
            CameraData cameraData = new CameraData(i, CameraData.FACING_FRONT);
            if(isBackFirst) {
                cameraDatas.add(cameraData);
            } else {
                cameraDatas.add(0, cameraData);
            }
        } else if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
            CameraData cameraData = new CameraData(i, CameraData.FACING_BACK);
            if(isBackFirst) {
                cameraDatas.add(0, cameraData);
            } else {
                cameraDatas.add(cameraData);
            }
        }
    }
    return cameraDatas;
}
複製程式碼

在上面的方法中,需要傳入一個是否先開啟背面攝像頭的 boolean 變數,如果變數為 true,則把背面攝像頭放在列表第一個,之後開啟攝像頭的時候,直接獲取列表中第一個攝像頭相關引數,然後進行開啟。這樣的設計使得切換攝像頭也變得十分簡單,切換攝像頭時,先關閉當前攝像頭,然後變化攝像頭列表中的順序,然後再開啟攝像頭即可,也就是每次開啟攝像頭都開啟攝像頭列表中第一個攝像頭引數所指向的攝像頭。

2.3 開啟攝像頭

開啟攝像頭之前,先從攝像頭列表中獲取第一個攝像頭引數,之後根據引數中的 CameraId 來開啟攝像頭,開啟成功後改變相關狀態。相關程式碼如下:

public synchronized Camera openCamera()
            throws CameraHardwareException, CameraNotSupportException {
    CameraData cameraData = mCameraDatas.get(0);
    if(mCameraDevice != null && mCameraData == cameraData) {
        return mCameraDevice;
    }
    if (mCameraDevice != null) {
        releaseCamera();
    }
    try {
        Log.d(TAG, "open camera " + cameraData.cameraID);
        mCameraDevice = Camera.open(cameraData.cameraID);
    } catch (RuntimeException e) {
        Log.e(TAG, "fail to connect Camera");
        throw new CameraHardwareException(e);
    }
    if(mCameraDevice == null) {
        throw new CameraNotSupportException();
    }
    mCameraData = cameraData;
    mState = State.OPENED;
    return mCameraDevice;
}
複製程式碼

上面需要注意的是,在 Android 提供的 Camera 原始碼中,Camera.open(cameraData.cameraID) 丟擲異常則說明Camera 不可用,否則說明 Camera 可用,但是在一些手機上 Camera.open(cameraData.cameraID) 不是丟擲異常,而是返回 null。

3. 配置攝像頭引數

在給攝像頭設定引數後,需要記錄這些引數,以方便其他地方使用。比如記錄當前攝像頭是否有閃光點,從而可以決定 UI 介面上是否顯示開啟閃光燈按鈕。在直播專案中使用 CameraData 來記錄這些引數,CameraData 類如下所示:

public class CameraData {
    public static final int FACING_FRONT = 1;
    public static final int FACING_BACK = 2;

    public int cameraID;            //camera的id
    public int cameraFacing;        //區分前後攝像頭
    public int cameraWidth;         //camera的採集寬度
    public int cameraHeight;        //camera的採集高度
    public boolean hasLight;        //camera是否有閃光燈
    public int orientation;         //camera旋轉角度
    public boolean supportTouchFocus;   //camera是否支援手動對焦
    public boolean touchFocusMode;      //camera是否處在自動對焦模式

    public CameraData(int id, int facing, int width, int height){
        cameraID = id;
        cameraFacing = facing;
        cameraWidth = width;
        cameraHeight = height;
    }

    public CameraData(int id, int facing) {
        cameraID = id;
        cameraFacing = facing;
    }
}
複製程式碼

給攝像頭設定引數的時候,有一點需要注意:設定的引數不生效會丟擲異常,因此需要每個引數單獨設定,這樣就避免一個引數不生效後丟擲異常,導致之後所有的引數都沒有設定。

4. 攝像頭開啟預覽

設定預覽介面有兩種方式:1、通過 SurfaceView 顯示;2、通過 GLSurfaceView 顯示。當為 SurfaceView 顯示時,需要傳給 Camera 這個 SurfaceView 的 SurfaceHolder。當使用 GLSurfaceView 顯示時,需要使用Renderer 進行渲染,先通過 OpenGL 生成紋理,通過生成紋理的紋理 id 生成 SurfaceTexture ,將SurfaceTexture 交給 Camera ,那麼在 Render 中便可以使用這個紋理進行相應的渲染,最後通過GLSurfaceView 顯示。

4.1 設定預覽回撥

public static void setPreviewFormat(Camera camera, Camera.Parameters parameters) {
    //設定預覽回撥的圖片格式
    try {
        parameters.setPreviewFormat(ImageFormat.NV21);
        camera.setParameters(parameters);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製程式碼

當設定預覽好預覽回撥的圖片格式後,需要設定預覽回撥的 Callback。

Camera.PreviewCallback myCallback = new Camera.PreviewCallback() {
    @Override
    public void onPreviewFrame(byte[] data, Camera camera) {
        //得到相應的圖片資料
        //Do something
    }
};
public static void setPreviewCallback(Camera camera, Camera.PreviewCallback callback) {
    camera.setPreviewCallback(callback);
}
複製程式碼

Android 推薦的 PreViewFormat 是 NV21,在 PreviewCallback 中會返回 Preview 的 N21 圖片。如果是軟編的話,由於 H264 支援 I420 的圖片格式,因此需要將 N21格式轉為 I420 格式,然後交給 x264 編碼庫。如果是硬編的話,由於 Android 硬編編碼器支援 I420(COLOR_FormatYUV420Planar) 和NV12(COLOR_FormatYUV420SemiPlanar),因此可以將 N21 的圖片轉為 I420 或者 NV12 ,然後交給硬編編碼器。

4.2 設定預覽影像大小

在攝像頭相關處理中,一個比較重要的是 螢幕顯示大小和攝像頭預覽大小比例不一致 的處理。在 Android 中,攝像頭有一系列的 PreviewSize,我們需要從中選出適合的 PreviewSize 。選擇合適的攝像頭 PreviewSize 的程式碼如下所示:

public static Camera.Size getOptimalPreviewSize(Camera camera, int width, int height) {
    Camera.Size optimalSize = null;
    double minHeightDiff = Double.MAX_VALUE;
    double minWidthDiff = Double.MAX_VALUE;
    List<Camera.Size> sizes = camera.getParameters().getSupportedPreviewSizes();
    if (sizes == null) return null;
    //找到寬度差距最小的
    for(Camera.Size size:sizes){
        if (Math.abs(size.width - width) < minWidthDiff) {
            minWidthDiff = Math.abs(size.width - width);
        }
    }
    //在寬度差距最小的裡面,找到高度差距最小的
    for(Camera.Size size:sizes){
        if(Math.abs(size.width - width) == minWidthDiff) {
            if(Math.abs(size.height - height) < minHeightDiff) {
                optimalSize = size;
                minHeightDiff = Math.abs(size.height - height);
            }
        }
    }
    return optimalSize;
}

public static void setPreviewSize(Camera camera, Camera.Size size, Camera.Parameters parameters) {
    try {    
        parameters.setPreviewSize(size.width, size.height);           
        camera.setParameters(parameters);
    } 
    catch (Exception e) {    
        e.printStackTrace();
    }
}
複製程式碼

在設定好最適合的 PreviewSize 之後,將 size 資訊儲存在 CameraData 中。當選擇了 SurfaceView 顯示的方式,可以將 SurfaceView 放置在一個 LinearLayout 中,然後根據攝像頭 PreviewSize 的比例改變 SurfaceView 的大小,從而使得兩者比例一致,確保影像正常。當選擇了GLSurfaceView 顯示的時候,可以通過裁剪紋理,使得紋理的大小比例和 GLSurfaceView 的大小比例保持一致,從而確保影像顯示正常。

4.3 影像旋轉

在 Android 中攝像頭出來的影像需要進行一定的旋轉,然後才能交給螢幕顯示,而且如果應用支援螢幕旋轉的話,也需要根據旋轉的狀況實時調整攝像頭的角度。在 Android 中旋轉攝像頭影像同樣有兩種方法,一是通過攝像頭的 setDisplayOrientation(result) 方法,一是通過 OpenGL 的矩陣進行旋轉。下面是通過setDisplayOrientation(result) 方法進行旋轉的程式碼:

public static int getDisplayRotation(Activity activity) {
    int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
    switch (rotation) {
        case Surface.ROTATION_0: return 0;
        case Surface.ROTATION_90: return 90;
        case Surface.ROTATION_180: return 180;
        case Surface.ROTATION_270: return 270;
    }
    return 0;
}

public static void setCameraDisplayOrientation(Activity activity, int cameraId, Camera camera) {
    // See android.hardware.Camera.setCameraDisplayOrientation for
    // documentation.
    Camera.CameraInfo info = new Camera.CameraInfo();
    Camera.getCameraInfo(cameraId, info);
    int degrees = getDisplayRotation(activity);
    int result;
    if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        result = (info.orientation + degrees) % 360;
        result = (360 - result) % 360; // compensate the mirror
    } else { // back-facing
        result = (info.orientation - degrees + 360) % 360;
    }
    camera.setDisplayOrientation(result);
}
複製程式碼

4.4 設定預覽幀率

通過 Camera.Parameters 中 getSupportedPreviewFpsRange() 可以獲得攝像頭支援的幀率變化範圍,從中選取合適的設定給攝像頭即可。相關的程式碼如下:

public static void setCameraFps(Camera camera, int fps) {
    Camera.Parameters params = camera.getParameters();
    int[] range = adaptPreviewFps(fps, params.getSupportedPreviewFpsRange());
    params.setPreviewFpsRange(range[0], range[1]);
    camera.setParameters(params);
}

private static int[] adaptPreviewFps(int expectedFps, List<int[]> fpsRanges) {
    expectedFps *= 1000;
    int[] closestRange = fpsRanges.get(0);
    int measure = Math.abs(closestRange[0] - expectedFps) + Math.abs(closestRange[1] - expectedFps);
    for (int[] range : fpsRanges) {
        if (range[0] <= expectedFps && range[1] >= expectedFps) {
            int curMeasure = Math.abs(range[0] - expectedFps) + Math.abs(range[1] - expectedFps);
            if (curMeasure < measure) {
                closestRange = range;
                measure = curMeasure;
            }
        }
    }
    return closestRange;
}
複製程式碼

4.5 設定相機對焦

一般攝像頭對焦的方式有兩種:手動對焦和觸控對焦。下面的程式碼分別是設定自動對焦和觸控對焦的模式:

public static void setAutoFocusMode(Camera camera) {
    try {
        Camera.Parameters parameters = camera.getParameters();
        List<String> focusModes = parameters.getSupportedFocusModes();
        if (focusModes.size() > 0 && focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
            parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
            camera.setParameters(parameters);
        } else if (focusModes.size() > 0) {
            parameters.setFocusMode(focusModes.get(0));
            camera.setParameters(parameters);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

public static void setTouchFocusMode(Camera camera) {
    try {
        Camera.Parameters parameters = camera.getParameters();
        List<String> focusModes = parameters.getSupportedFocusModes();
        if (focusModes.size() > 0 && focusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) {
            parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
            camera.setParameters(parameters);
        } else if (focusModes.size() > 0) {
            parameters.setFocusMode(focusModes.get(0));
            camera.setParameters(parameters);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製程式碼

對於自動對焦這樣設定後就完成了工作,但是對於觸控對焦則需要設定對應的對焦區域。要準確地設定對焦區域,有三個步驟:一、得到當前點選的座標位置;二、通過點選的座標位置轉換到攝像頭預覽介面座標系統上的座標;三、根據座標生成對焦區域並且設定給攝像頭。整個攝像頭預覽介面定義瞭如下的座標系統,對焦區域也需要對應到這個座標系統中。

音視訊學習 (十一) Android 端實現 rtmp 推流

如果攝像機預覽介面是通過 SurfaceView 顯示的則比較簡單,由於要確保不變形,會將 SurfaceView 進行拉伸,從而使得 SurfaceView 和預覽影像大小比例一致,因此整個 SurfaceView 相當於預覽介面,只需要得到當前點選點在整個 SurfaceView 上對應的座標,然後轉化為相應的對焦區域即可。如果攝像機預覽介面是通過GLSurfaceView 顯示的則要複雜一些,由於紋理需要進行裁剪,才能使得顯示不變形,這樣的話,我們要還原出整個預覽介面的大小,然後通過當前點選的位置換算成預覽介面座標系統上的座標,然後得到相應的對焦區域,然後設定給攝像機。當設定好對焦區域後,通過呼叫 Camera 的 autoFocus() 方法即可完成觸控對焦。 整個過程程式碼量較多,請自行閱讀專案原始碼。

4.6 設定縮放

當檢測到手勢縮放的時候,我們往往希望攝像頭也能進行相應的縮放,其實這個實現還是比較簡單的。首先需要加入縮放的手勢識別,當識別到縮放的手勢的時候,根據縮放的大小來對攝像頭進行縮放。程式碼如下所示:

/**
 * Handles the pinch-to-zoom gesture
 */
private class ZoomGestureListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
    @Override
    public boolean onScale(ScaleGestureDetector detector) {
        if (!mIsFocusing) {
            float progress = 0;
            if (detector.getScaleFactor() > 1.0f) {
                progress = CameraHolder.instance().cameraZoom(true);
            } else if (detector.getScaleFactor() < 1.0f) {
                progress = CameraHolder.instance().cameraZoom(false);
            } else {
                return false;
            }
            if(mZoomListener != null) {
                mZoomListener.onZoomProgress(progress);
            }
        }
        return true;
    }
}

public float cameraZoom(boolean isBig) {
    if(mState != State.PREVIEW || mCameraDevice == null || mCameraData == null) {
        return -1;
    }
    Camera.Parameters params = mCameraDevice.getParameters();
    if(isBig) {
        params.setZoom(Math.min(params.getZoom() + 1, params.getMaxZoom()));
    } else {
        params.setZoom(Math.max(params.getZoom() - 1, 0));
    }
    mCameraDevice.setParameters(params);
    return (float) params.getZoom()/params.getMaxZoom();
}
複製程式碼

4.7 閃光燈操作

一個攝像頭可能有相應的閃光燈,也可能沒有,因此在使用閃光燈功能的時候先要確認是否有相應的閃光燈。檢測攝像頭是否有閃光燈的程式碼如下:

public static boolean supportFlash(Camera camera){
    Camera.Parameters params = camera.getParameters();
    List<String> flashModes = params.getSupportedFlashModes();
    if(flashModes == null) {
        return false;
    }
    for(String flashMode : flashModes) {
        if(Camera.Parameters.FLASH_MODE_TORCH.equals(flashMode)) {
            return true;
        }
    }
    return false;
}
複製程式碼

切換閃光燈的程式碼如下:

public static void switchLight(Camera camera, Camera.Parameters cameraParameters) {
    if (cameraParameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_OFF)) {
        cameraParameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH);
    } else {
        cameraParameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
    }
    try {
        camera.setParameters(cameraParameters);
    }catch (Exception e) {
        e.printStackTrace();
    }
}
複製程式碼

4.8 開始預覽

當開啟了攝像頭,並且設定好了攝像頭相關的引數後,便可以通過呼叫 Camera 的 startPreview() 方法開始預覽。有一個需要說明,無論是 SurfaceView 還是 GLSurfaceView ,都可以設定 SurfaceHolder.Callback ,當介面開始顯示的時候開啟攝像頭並且開始預覽,當介面銷燬的時候停止預覽並且關閉攝像頭,這樣的話當程式退到後臺,其他應用也能呼叫攝像頭。

private SurfaceHolder.Callback mSurfaceHolderCallback = new SurfaceHolder.Callback() {
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        Log.d(SopCastConstant.TAG, "SurfaceView destroy");
        CameraHolder.instance().stopPreview();
        CameraHolder.instance().releaseCamera();
    }

    @TargetApi(Build.VERSION_CODES.GINGERBREAD)
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        Log.d(SopCastConstant.TAG, "SurfaceView created");
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
     Log.d(SopCastConstant.TAG, "SurfaceView width:" + width + " height:" + height);
        CameraHolder.instance().openCamera();
        CameraHolder.instance().startPreview();
    }
};
複製程式碼

5. 停止預覽

停止預覽只需要釋放掉相機資源即可:

    public synchronized void releaseCamera() {
        if (mState == State.PREVIEW) {
            stopPreview();
        }
        if (mState != State.OPENED) {
            return;
        }
        if (mCameraDevice == null) {
            return;
        }
        mCameraDevice.release();
        mCameraDevice = null;
        mCameraData = null;
        mState = State.INIT;
    }
複製程式碼

音訊編碼

AudioRecord 採集完之後需要對 PCM 資料進行實時的編碼 (軟編利用 libfaac 通過 NDK 交叉編譯靜態庫、硬編使用 Android SDK MediaCodec 進行編碼)。

軟編

語音軟編這裡們用主流的編碼庫 libfaac 進行編碼 AAC 語音格式資料。

1. 編譯 libfaac

1.1 下載 libfaac
wget https://sourceforge.net/projects/faac/files/faac-src/faac-1.29/faac-1.29.9.2.tar.gz
複製程式碼
1.2 編寫交叉編譯指令碼
#!/bin/bash

#打包地址
PREFIX=`pwd`/android/armeabi-v7a
#配置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

FLAGS="-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"

CROSS_COMPILE=$TOOLCHAIN/bin/arm-linux-androideabi
export CC="$CROSS_COMPILE-gcc --sysroot=$NDK_ROOT/platforms/android-17/arch-arm"
export CFLAGS="$FLAGS"

./configure \
--prefix=$PREFIX \
--host=arm-linux \
--with-pic \
--enable-shared=no

make clean
make install
複製程式碼

2. CMakeLists.txt 配置

cmake_minimum_required(VERSION 3.4.1)
#語音編碼器
set(faac ${CMAKE_SOURCE_DIR}/faac)
#載入 faac 標頭檔案目錄
include_directories(${faac}/include)
#指定 faac 靜態庫檔案目錄
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${faac}/libs/${CMAKE_ANDROID_ARCH_ABI}")
#批量新增自己編寫的 cpp 檔案,不要把 *.h 加入進來了
file(GLOB Push_CPP ${ykpusher}/*.cpp)
#新增自己編寫 cpp 原始檔生成動態庫
add_library(ykpusher SHARED ${Push_CPP})
#找系統中 NDK log庫
find_library(log_lib
        log)
#推流 so
target_link_libraries(
        #播放 so
        ykpusher
#        # 寫了此命令不用在乎新增 ffmpeg lib 順序問題導致應用崩潰
#        -Wl,--start-group
#        avcodec avfilter avformat avutil swresample swscale
#        -Wl,--end-group
#        z
        #推流庫
        rtmp
        #視訊編碼
        x264
        #語音編碼
        faac
        #本地庫
        android
        ${log_lib}
        )

複製程式碼

3. 配置 faac 編碼引數

//設定語音軟編碼引數
void AudioEncoderChannel::setAudioEncoderInfo(int samplesHZ, int channel) {
  	//如果已經初始化,需要釋放
    release();
    //通道 預設單聲道
    mChannels = channel;
    //開啟編碼器
    //3、一次最大能輸入編碼器的樣本數量 也編碼的資料的個數 (一個樣本是16位 2位元組)
    //4、最大可能的輸出資料  編碼後的最大位元組數
    mAudioCodec = faacEncOpen(samplesHZ, channel, &mInputSamples, &mMaxOutputBytes);
    if (!mAudioCodec) {
        if (mIPushCallback) {
            mIPushCallback->onError(THREAD_MAIN, FAAC_ENC_OPEN_ERROR);
        }
        return;
    }

    //設定編碼器引數
    faacEncConfigurationPtr config = faacEncGetCurrentConfiguration(mAudioCodec);
    //指定為 mpeg4 標準
    config->mpegVersion = MPEG4;
    //lc 標準
    config->aacObjectType = LOW;
    //16位
    config->inputFormat = FAAC_INPUT_16BIT;
    // 編碼出原始資料 既不是adts也不是adif
    config->outputFormat = 0;
    faacEncSetConfiguration(mAudioCodec, config);
    //輸出緩衝區 編碼後的資料 用這個緩衝區來儲存
    mBuffer = new u_char[mMaxOutputBytes];
  	//設定一個標誌,用於開啟編碼
    isStart = true;
}
複製程式碼

4. 配置 AAC 包頭

在傳送 rtmp 音視訊包的時候需要將語音包頭第一個傳送

/**
 * 音訊頭包資料
 * @return
 */
RTMPPacket *AudioEncoderChannel::getAudioTag() {
    if (!mAudioCodec) {
        setAudioEncoderInfo(FAAC_DEFAUTE_SAMPLE_RATE, FAAC_DEFAUTE_SAMPLE_CHANNEL);
        if (!mAudioCodec)return 0;
    }
    u_char *buf;
    u_long len;
    faacEncGetDecoderSpecificInfo(mAudioCodec, &buf, &len);
    int bodySize = 2 + len;
    RTMPPacket *packet = new RTMPPacket;
    RTMPPacket_Alloc(packet, bodySize);
    //雙聲道
    packet->m_body[0] = 0xAF;
    if (mChannels == 1) { //單身道
        packet->m_body[0] = 0xAE;
    }
    packet->m_body[1] = 0x00;
    //將包頭資料 copy 到RTMPPacket 中
    memcpy(&packet->m_body[2], buf, len);
		//是否使用絕對時間戳
    packet->m_hasAbsTimestamp = FALSE;
  	//包大小
    packet->m_nBodySize = bodySize;
  	//包型別
    packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
  	//語音通道
    packet->m_nChannel = 0x11;
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    return packet;
}
複製程式碼

5. 開始實時編碼

void AudioEncoderChannel::encodeData(int8_t *data) {
    if (!mAudioCodec || !isStart)//不符合編碼要求,退出
        return;
    //返回編碼後的資料位元組長度
    int bytelen = faacEncEncode(mAudioCodec, reinterpret_cast<int32_t *>(data), mInputSamples,mBuffer, mMaxOutputBytes);
    if (bytelen > 0) {
        //開始打包 rtmp
        int bodySize = 2 + bytelen;
        RTMPPacket *packet = new RTMPPacket;
        RTMPPacket_Alloc(packet, bodySize);
        //雙聲道
        packet->m_body[0] = 0xAF;
        if (mChannels == 1) {
            packet->m_body[0] = 0xAE;
        }
        //編碼出的音訊 都是 0x01
        packet->m_body[1] = 0x01;
        memcpy(&packet->m_body[2], mBuffer, bytelen);

        packet->m_hasAbsTimestamp = FALSE;
        packet->m_nBodySize = bodySize;
        packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
        packet->m_nChannel = 0x11;
        packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
        //傳送 rtmp packet,回撥給 RTMP send 模組
        mAudioCallback(packet);
    }
}
複製程式碼

6. 釋放編碼器

在不需要編碼或者退出編碼的時候需要主動釋放編碼器,釋放 native 記憶體,可以通過如下函式來實現釋放編碼器的操作:

void AudioEncoderChannel::release() {
  	//退出編碼的標誌
    isStart = false;
    //釋放編碼器
    if (mAudioCodec) {
      	//關閉編碼器
        faacEncClose(mAudioCodec);
      	//釋放緩衝區
      	DELETE(mBuffer);
        mAudioCodec = 0;
    }
}
複製程式碼

硬編

軟編碼介紹完了下面利用 Android SDK 自帶的 MediaCodec 函式進行對 PCM 編碼為 AAC 的格式音訊資料。使用 MediaCodec 編碼 AAC 對 Android 系統是有要求的,必須是 4.1系統以上,即要求 Android 的版本代號在 Build.VERSION_CODES.JELLY_BEAN (16) 以上。MediaCodec 是 Android 系統提供的硬體編碼器,它可以利用裝置的硬體來完成編碼,從而大大提高編碼的效率,還可以降低電量的使用,但是其在相容性方面不如軟編號,因為 Android 裝置的鎖片化太嚴重,所以讀者可以自己衡量在應用中是否使用 Android 平臺的硬體編碼特性。

1. 建立 "audio/mp4a-latm" 型別的硬編碼器

 mediaCodec = MediaCodec.createEncoderByType(configuration.mime);    
複製程式碼

2. 配置音訊硬編碼器

    public static MediaCodec getAudioMediaCodec(AudioConfiguration configuration){
        MediaFormat format = MediaFormat.createAudioFormat(configuration.mime, configuration.frequency, configuration.channelCount);
        if(configuration.mime.equals(AudioConfiguration.DEFAULT_MIME)) {
            format.setInteger(MediaFormat.KEY_AAC_PROFILE, configuration.aacProfile);
        }
      	//語音位元速率
        format.setInteger(MediaFormat.KEY_BIT_RATE, configuration.maxBps * 1024);
      	//語音取樣率 44100
        format.setInteger(MediaFormat.KEY_SAMPLE_RATE, configuration.frequency);
        int maxInputSize = AudioUtils.getRecordBufferSize(configuration);
        format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize);
        format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, configuration.channelCount);

        MediaCodec mediaCodec = null;
        try {
            mediaCodec = MediaCodec.createEncoderByType(configuration.mime);
          	//MediaCodec.CONFIGURE_FLAG_ENCODE 代表編碼器,解碼傳 0 即可
            mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        } catch (Exception e) {
            e.printStackTrace();
            if (mediaCodec != null) {
                mediaCodec.stop();
                mediaCodec.release();
                mediaCodec = null;
            }
        }
        return mediaCodec;
    }
複製程式碼

3. 開啟音訊硬編碼器

void prepareEncoder() {
   mMediaCodec = AudioMediaCodec.getAudioMediaCodec(mAudioConfiguration);
   mMediaCodec.start();
}
複製程式碼

4. 拿到硬編碼輸入(PCM)輸出(AAC) ByteBufferer

到了這一步說明,音訊編碼器配置完成並且也成功開啟了,現在就可以從 MediaCodec 例項中獲取兩個 buffer ,一個是輸入 buffer 一個是輸出 buffer , 輸入 buffer 類似於 FFmpeg 中的 AVFrame 存放待編碼的 PCM 資料,輸出 buffer 類似於 FFmpeg 的 AVPacket 編碼之後的 AAC 資料, 其程式碼如下:

//存放的是 PCM 資料
ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
//存放的是編碼之後的 AAC 資料
ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();
複製程式碼

5. 開始 PCM 硬編碼為 AAC

到此,所有初始化方法已實現完畢,下面來看一下 MediaCodec 的工作原理如下圖所示,左邊 Client 元素代表要將 PCM 放到 inputBuffer 中的某個具體的 buffer 中去,右邊的 Client 元素代表將編碼之後的原始 AAC 資料從 outputBuffer 中的某個具體 buffer 中取出來,? 左邊的小方塊代表各個 inputBuffer 元素,右邊的小方塊則代表各個 outputBuffer 元素。詳細介紹可以看 MediaCodec 類介紹

音視訊學習 (十一) Android 端實現 rtmp 推流

程式碼具體實現如下:

  //input:PCM  
	synchronized void offerEncoder(byte[] input) {
        if(mMediaCodec == null) {
            return;
        }
        ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
        ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();
        int inputBufferIndex = mMediaCodec.dequeueInputBuffer(12000);
        if (inputBufferIndex >= 0) {
            ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
            inputBuffer.clear();
            inputBuffer.put(input);
            mMediaCodec.queueInputBuffer(inputBufferIndex, 0, input.length, 0, 0);
        }

        int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 12000);
        while (outputBufferIndex >= 0) {
            ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
            if(mListener != null) {
              	//將 AAC 資料回撥出去
                mListener.onAudioEncode(outputBuffer, mBufferInfo);
            }
          	//釋放當前內部編碼記憶體
            mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);
            outputBufferIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 0);
        }
    }
複製程式碼

6. AAC 打包為 flv

    @Override
    public void onAudioData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        if (packetListener == null || !isHeaderWrite || !isKeyFrameWrite) {
            return;
        }
        bb.position(bi.offset);
        bb.limit(bi.offset + bi.size);

        byte[] audio = new byte[bi.size];
        bb.get(audio);
        int size = AUDIO_HEADER_SIZE + audio.length;
        ByteBuffer buffer = ByteBuffer.allocate(size);
        FlvPackerHelper.writeAudioTag(buffer, audio, false, mAudioSampleSize);
        packetListener.onPacket(buffer.array(), AUDIO);
    }

    public static void writeAudioTag(ByteBuffer buffer, byte[] audioInfo, boolean isFirst, int audioSize) {
        //寫入音訊頭資訊
        writeAudioHeader(buffer, isFirst, audioSize);

        //寫入音訊資訊
        buffer.put(audioInfo);
    }
複製程式碼

7. 釋放編碼器

在使用完 MediaCodec 編碼器之後,就需要停止執行並釋放編碼器,程式碼如下:

    synchronized public void stop() {
        if (mMediaCodec != null) {
            mMediaCodec.stop();
            mMediaCodec.release();
            mMediaCodec = null;
        }
    }
複製程式碼

視訊編碼

Camera 採集完之後需要對 YUV 資料進行實時的編碼 (軟編利用 x264 通過 NDK 交叉編譯靜態庫、硬編使用 Android SDK MediaCodec 進行編碼)。

軟編

視訊軟編這裡們用主流的編碼庫 x264 進行編碼 H264 視訊格式資料。

1. 交叉編譯 x264

1.1 下載 x264
//方式 一
git clone https://code.videolan.org/videolan/x264.git
//方式 二
wget ftp://ftp.videolan.org/pub/x264/snapshots/last_x264.tar.bz2
複製程式碼
1.2 編寫編譯指令碼

在編寫指令碼之前需要在 configure 中新增一處程式碼 -Werror=implicit-function-declaration,如下所示:

音視訊學習 (十一) Android 端實現 rtmp 推流

交叉編譯指令碼如下:

#!/bin/bash

#打包地址
PREFIX=./android/armeabi-v7a

#配置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

FLAGS="-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"

#--disable-cli 不需要命令列工具
#--enable-static 靜態庫


./configure \
--prefix=$PREFIX \
--disable-cli \
--enable-static \
--enable-pic \
--host=arm-linux \
--cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
--sysroot=$NDK_ROOT/platforms/android-17/arch-arm \
--extra-cflags="$FLAGS"

make clean
make install
複製程式碼

2. CMakeList.txt 配置

cmake_minimum_required(VERSION 3.4.1)

#視訊編碼器
set(x264 ${CMAKE_SOURCE_DIR}/x264)

#載入 x264 標頭檔案目錄
include_directories(${x264}/include)

#指定 x264 靜態庫檔案目錄
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${x264}/libs/${CMAKE_ANDROID_ARCH_ABI}")

#批量新增自己編寫的 cpp 檔案,不要把 *.h 加入進來了
file(GLOB Player_CPP ${ykplayer}/*.cpp)
file(GLOB Push_CPP ${ykpusher}/*.cpp)
#新增自己編寫 cpp 原始檔生成動態庫
add_library(ykpusher SHARED ${Push_CPP})

#找系統中 NDK log庫
find_library(log_lib
        log)

#推流 so
target_link_libraries(
        #播放 so
        ykpusher
#        # 寫了此命令不用在乎新增 ffmpeg lib 順序問題導致應用崩潰
#        -Wl,--start-group
#        avcodec avfilter avformat avutil swresample swscale
#        -Wl,--end-group
#        z
        #推流庫
        rtmp
        #視訊編碼
        x264
        #語音編碼
        faac
        #本地庫
        android
        ${log_lib}
        )
複製程式碼

3. 配置並開啟 x264 編碼器

void VideoEncoderChannel::setVideoEncoderInfo(int width, int height, int fps, int bit) {
    pthread_mutex_lock(&mMutex);
    this->mWidth = width;
    this->mHeight = height;
    this->mFps = fps;
    this->mBit = bit;
    this->mY_Size = width * height;
    this->mUV_Size = mY_Size / 4;

    //如果編碼器已經存在,需要釋放
    if (mVideoCodec || pic_in) {
        release();
    }
    //開啟x264編碼器
    //x264編碼器的屬性
    x264_param_t param;
    //2: 最快
    //3:  無延遲編碼
    x264_param_default_preset(&param, x264_preset_names[0], x264_tune_names[7]);
    //base_line 3.2 編碼規格
    param.i_level_idc = 32;
    //輸入資料格式
    param.i_csp = X264_CSP_I420;
    param.i_width = width;
    param.i_height = height;
    //無b幀
    param.i_bframe = 0;
    //引數i_rc_method表示位元速率控制,CQP(恆定質量),CRF(恆定位元速率),ABR(平均位元速率)
    param.rc.i_rc_method = X264_RC_ABR;
    //位元速率(位元率,單位Kbps)
    param.rc.i_bitrate = mBit;
    //瞬時最大位元速率
    param.rc.i_vbv_max_bitrate = mBit * 1.2;
    //設定了i_vbv_max_bitrate必須設定此引數,位元速率控制區大小,單位kbps
    param.rc.i_vbv_buffer_size = mBit;

    //幀率
    param.i_fps_num = fps;
    param.i_fps_den = 1;
    param.i_timebase_den = param.i_fps_num;
    param.i_timebase_num = param.i_fps_den;
//    param.pf_log = x264_log_default2;
    //用fps而不是時間戳來計算幀間距離
    param.b_vfr_input = 0;
    //幀距離(關鍵幀)  2s一個關鍵幀
    param.i_keyint_max = fps * 2;
    // 是否複製sps和pps放在每個關鍵幀的前面 該引數設定是讓每個關鍵幀(I幀)都附帶sps/pps。
    param.b_repeat_headers = 1;
    //多執行緒
    param.i_threads = 1;

    x264_param_apply_profile(&param, "baseline");
    //開啟編碼器
    mVideoCodec = x264_encoder_open(&param);
    pic_in = new x264_picture_t;
    x264_picture_alloc(pic_in, X264_CSP_I420, width, height);
    //相當於重啟編碼器
    isStart = true;
    pthread_mutex_unlock(&mMutex);
}
複製程式碼

4. 開始編碼

void VideoEncoderChannel::onEncoder() {
    while (isStart) {
        if (!mVideoCodec) {
            continue;
        }
        int8_t *data = 0;
        mVideoPackets.pop(data);
        if (!data) {
            LOGE("獲取 YUV 資料錯誤");
            continue;
        }
        //copy Y 資料
        memcpy(this->pic_in->img.plane[0], data, mY_Size);
        //拿到 UV 資料
        for (int i = 0; i < mUV_Size; ++i) {
            //拿到 u 資料
            *(pic_in->img.plane[1] + i) = *(data + mY_Size + i * 2 + 1);
            //拿到 v 資料
            *(pic_in->img.plane[2] + i) = *(data + mY_Size + i * 2);
        }
        //編碼出來的資料
        x264_nal_t *pp_nal;
        //編碼出來的幀數量
        int pi_nal = 0;
        x264_picture_t pic_out;
        //開始編碼
        int ret = x264_encoder_encode(mVideoCodec, &pp_nal, &pi_nal, pic_in, &pic_out);
        if (!ret) {
            LOGE("編碼失敗");
            continue;
        }
        //如果是關鍵幀
        int sps_len = 0;
        int pps_len = 0;
        uint8_t sps[100];
        uint8_t pps[100];
        for (int i = 0; i < pi_nal; ++i) {
            if (pp_nal[i].i_type == NAL_SPS) {
                //排除掉 h264的間隔 00 00 00 01
                sps_len = pp_nal[i].i_payload - 4;
                memcpy(sps, pp_nal[i].p_payload + 4, sps_len);
            } else if (pp_nal[i].i_type == NAL_PPS) {
                pps_len = pp_nal[i].i_payload - 4;
                memcpy(pps, pp_nal[i].p_payload + 4, pps_len);
                //pps肯定是跟著sps的
                sendSpsPps(sps, pps, sps_len, pps_len);
            } else {
              	//編碼之後的 H264 資料
                sendFrame(pp_nal[i].i_type, pp_nal[i].p_payload, pp_nal[i].i_payload, 0);
            }
        }
    }
}

/**
 * 傳送 sps pps
 * @param sps  編碼第一幀資料
 * @param pps  編碼第二幀資料
 * @param sps_len  編碼第一幀資料的長度
 * @param pps_len  編碼第二幀資料的長度
 */
void VideoEncoderChannel::sendSpsPps(uint8_t *sps, uint8_t *pps, int sps_len, int pps_len) {
    int bodySize = 13 + sps_len + 3 + pps_len;
    RTMPPacket *packet = new RTMPPacket;
    //
    RTMPPacket_Alloc(packet, bodySize);
    int i = 0;
    //固定頭
    packet->m_body[i++] = 0x17;
    //型別
    packet->m_body[i++] = 0x00;
    //composition time 0x000000
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;

    //版本
    packet->m_body[i++] = 0x01;
    //編碼規格
    packet->m_body[i++] = sps[1];
    packet->m_body[i++] = sps[2];
    packet->m_body[i++] = sps[3];
    packet->m_body[i++] = 0xFF;

    //整個sps
    packet->m_body[i++] = 0xE1;
    //sps長度
    packet->m_body[i++] = (sps_len >> 8) & 0xff;
    packet->m_body[i++] = sps_len & 0xff;
    memcpy(&packet->m_body[i], sps, sps_len);
    i += sps_len;

    //pps
    packet->m_body[i++] = 0x01;
    packet->m_body[i++] = (pps_len >> 8) & 0xff;
    packet->m_body[i++] = (pps_len) & 0xff;
    memcpy(&packet->m_body[i], pps, pps_len);

    //視訊
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    packet->m_nBodySize = bodySize;
    //隨意分配一個管道(儘量避開rtmp.c中使用的)
    packet->m_nChannel = 0x10;
    //sps pps沒有時間戳
    packet->m_nTimeStamp = 0;
    //不使用絕對時間
    packet->m_hasAbsTimestamp = 0;
    packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM;
    if (mVideoCallback && isStart)
        mVideoCallback(packet);
}

/**
 * 傳送視訊幀 -- 關鍵幀
 * @param type
 * @param payload
 * @param i_playload
 */
void VideoEncoderChannel::sendFrame(int type, uint8_t *payload, int i_payload, long timestamp) {
    if (payload[2] == 0x00) {
        i_payload -= 4;
        payload += 4;
    } else {
        i_payload -= 3;
        payload += 3;
    }
    //看錶
    int bodySize = 9 + i_payload;
    RTMPPacket *packet = new RTMPPacket;
    //
    RTMPPacket_Alloc(packet, bodySize);

    packet->m_body[0] = 0x27;
    if (type == NAL_SLICE_IDR) {
        packet->m_body[0] = 0x17;
        LOGE("關鍵幀");
    }
    //型別
    packet->m_body[1] = 0x01;
    //時間戳
    packet->m_body[2] = 0x00;
    packet->m_body[3] = 0x00;
    packet->m_body[4] = 0x00;
    //資料長度 int 4個位元組
    packet->m_body[5] = (i_payload >> 24) & 0xff;
    packet->m_body[6] = (i_payload >> 16) & 0xff;
    packet->m_body[7] = (i_payload >> 8) & 0xff;
    packet->m_body[8] = (i_payload) & 0xff;

    //圖片資料
    memcpy(&packet->m_body[9], payload, i_payload);

    packet->m_hasAbsTimestamp = 0;
    packet->m_nBodySize = bodySize;
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    packet->m_nChannel = 0x10;
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    if (mVideoCallback && isStart)
        mVideoCallback(packet);//回撥給 RTMP 模組
}
複製程式碼

5. 釋放編碼器

當我們不需要編碼的時候需要釋放編碼器,程式碼如下:

x264_encoder_close(mVideoCodec);
複製程式碼

硬編

在 Android 4.3 系統以後,用 MediaCodec 編碼視訊成為了主流的使用場景,儘管 Android 的碎片化很嚴重,會導致一些相容性問題,但是硬體編碼器的效能以及速度是非常可觀的,並且在 4.3 系統之後可以通過 Surface 來配置編碼器的輸入,大大降低了視訊記憶體到記憶體的交換過程所使用的時間,從而使得整個應用的體驗得到大大提升。由於輸入和輸出已經確定,因此接下來將直接編寫 MediaCodec 編碼視訊幀的過程。

1. 建立 video/avc 型別的硬編碼器

 mediaCodec = MediaCodec.createEncoderByType(videoConfiguration.mime);
複製程式碼

2. 配置視訊編碼器

    public static MediaCodec getVideoMediaCodec(VideoConfiguration videoConfiguration) {
        int videoWidth = getVideoSize(videoConfiguration.width);
        int videoHeight = getVideoSize(videoConfiguration.height);
        MediaFormat format = MediaFormat.createVideoFormat(videoConfiguration.mime, videoWidth, videoHeight);
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        format.setInteger(MediaFormat.KEY_BIT_RATE, videoConfiguration.maxBps* 1024);
        int fps = videoConfiguration.fps;
        //設定攝像頭預覽幀率
        if(BlackListHelper.deviceInFpsBlacklisted()) {
            SopCastLog.d(SopCastConstant.TAG, "Device in fps setting black list, so set mediacodec fps 15");
            fps = 15;
        }
        format.setInteger(MediaFormat.KEY_FRAME_RATE, fps);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, videoConfiguration.ifi);
        format.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);
        format.setInteger(MediaFormat.KEY_COMPLEXITY, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR);
        MediaCodec mediaCodec = null;

        try {
            mediaCodec = MediaCodec.createEncoderByType(videoConfiguration.mime);
            mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        }catch (Exception e) {
            e.printStackTrace();
            if (mediaCodec != null) {
                mediaCodec.stop();
                mediaCodec.release();
                mediaCodec = null;
            }
        }
        return mediaCodec;
    }
複製程式碼

3. 開啟視訊編碼器

mMediaCodec.start();
複製程式碼

4. 拿到編碼之後的資料

	private void drainEncoder() {
		ByteBuffer[] outBuffers = mMediaCodec.getOutputBuffers();
		while (isStarted) {
			encodeLock.lock();
			if(mMediaCodec != null) {
				int outBufferIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 12000);
				if (outBufferIndex >= 0) {
					ByteBuffer bb = outBuffers[outBufferIndex];
					if (mListener != null) { //將編碼好的 H264 資料回撥出去
						mListener.onVideoEncode(bb, mBufferInfo);
					}
					mMediaCodec.releaseOutputBuffer(outBufferIndex, false);
				} else {
					try {
						// wait 10ms
						Thread.sleep(10);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
				encodeLock.unlock();
			} else {
				encodeLock.unlock();
				break;
			}
		}
	}
複製程式碼

5. H264 打包為 flv

    //接收 H264 資料 
		@Override
    public void onVideoData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        mAnnexbHelper.analyseVideoData(bb, bi);
    }   
	/**
     * 將硬編得到的視訊資料進行處理生成每一幀視訊資料,然後傳給flv打包器
     * @param bb 硬編後的資料buffer
     * @param bi 硬編的BufferInfo
     */
    public void analyseVideoData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        bb.position(bi.offset);
        bb.limit(bi.offset + bi.size);

        ArrayList<byte[]> frames = new ArrayList<>();
        boolean isKeyFrame = false;

        while(bb.position() < bi.offset + bi.size) {
            byte[] frame = annexbDemux(bb, bi);
            if(frame == null) {
                LogUtils.e("annexb not match.");
                break;
            }
            // ignore the nalu type aud(9)
            if (isAccessUnitDelimiter(frame)) {
                continue;
            }
            // for pps
            if(isPps(frame)) {
                mPps = frame;
                continue;
            }
            // for sps
            if(isSps(frame)) {
                mSps = frame;
                continue;
            }
            // for IDR frame
            if(isKeyFrame(frame)) {
                isKeyFrame = true;
            } else {
                isKeyFrame = false;
            }
            byte[] naluHeader = buildNaluHeader(frame.length);
            frames.add(naluHeader);
            frames.add(frame);
        }
        if (mPps != null && mSps != null && mListener != null && mUploadPpsSps) {
            if(mListener != null) {
                mListener.onSpsPps(mSps, mPps);
            }
            mUploadPpsSps = false;
        }
        if(frames.size() == 0 || mListener == null) {
            return;
        }
        int size = 0;
        for (int i = 0; i < frames.size(); i++) {
            byte[] frame = frames.get(i);
            size += frame.length;
        }
        byte[] data = new byte[size];
        int currentSize = 0;
        for (int i = 0; i < frames.size(); i++) {
            byte[] frame = frames.get(i);
            System.arraycopy(frame, 0, data, currentSize, frame.length);
            currentSize += frame.length;
        }
        if(mListener != null) {
            mListener.onVideo(data, isKeyFrame);
        }
    }

複製程式碼

這個方法主要是從編碼後的資料中解析得到NALU,然後判斷NALU的型別,最後再把資料回撥給 FlvPacker 去處理。

處理 spsPps:

    @Override
    public void onSpsPps(byte[] sps, byte[] pps) {
        if (packetListener == null) {
            return;
        }
        //寫入第一個視訊資訊
        writeFirstVideoTag(sps, pps);
        //寫入第一個音訊資訊
        writeFirstAudioTag();
        isHeaderWrite = true;
    }

複製程式碼

處理視訊幀:

    @Override
    public void onVideo(byte[] video, boolean isKeyFrame) {
        if (packetListener == null || !isHeaderWrite) {
            return;
        }
        int packetType = INTER_FRAME;
        if (isKeyFrame) {
            isKeyFrameWrite = true;
            packetType = KEY_FRAME;
        }
        //確保第一幀是關鍵幀,避免一開始出現灰色模糊介面
        if (!isKeyFrameWrite) {
            return;
        }
        int size = VIDEO_HEADER_SIZE + video.length;
        ByteBuffer buffer = ByteBuffer.allocate(size);
        FlvPackerHelper.writeH264Packet(buffer, video, isKeyFrame);
        packetListener.onPacket(buffer.array(), packetType);
    }
複製程式碼

6. 釋放編碼器,並釋放 Surface

	//釋放編碼器
	private void releaseEncoder() {
		if (mMediaCodec != null) {
			mMediaCodec.signalEndOfInputStream();
			mMediaCodec.stop();
			mMediaCodec.release();
			mMediaCodec = null;
		}
		if (mInputSurface != null) {
			mInputSurface.release();
			mInputSurface = null;
		}
	}

	//釋放 OpenGL ES 渲染,Surface
	public void release() {
		EGL14.eglDestroySurface(mEGLDisplay, mEGLSurface);
		EGL14.eglDestroyContext(mEGLDisplay, mEGLContext);
		EGL14.eglReleaseThread();
		EGL14.eglTerminate(mEGLDisplay);

		mSurface.release();

		mSurface    = null;
		mEGLDisplay = null;
		mEGLContext = null;
		mEGLSurface = null;
	}
複製程式碼

rtmp 推流

注: 實際專案 rtmp 需要先連線上才有後續操作。

rtmp 模組我們已在開發 播放器 的時候,將它和 ffmpeg 一併編譯了。所以我們直接使用上次的靜態庫和標頭檔案就可以了,如果對 rtmp 協議不瞭解的可以參考上一篇文章,裡面也有介紹 搭建 RTMP 直播伺服器

到這裡軟編碼和硬編碼資料都已準備好了現在,需要傳送給 rtmp 模組,也就是在 native 中,先看 java 傳送出口:

    /**
     * 打包之後的資料,和裸流資料
     *
     * @param data
     * @param type
     */
    @Override
    public void onData(byte[] data, int type) {
        if (type == RtmpPacker.FIRST_AUDIO || type == RtmpPacker.AUDIO) {//音訊 AAC 資料,已打包 
            mPusherManager.pushAACData(data, data.length, type);
        } else if (type == RtmpPacker.FIRST_VIDEO ||
                type == RtmpPacker.INTER_FRAME || type == RtmpPacker.KEY_FRAME) {//H264 視訊資料,已打包
            mPusherManager.pushH264(data, type, 0);
        } else if (type == RtmpPacker.PCM) { //PCM 裸流資料
            mPusherManager.pushPCM(data);
        } else if (type == RtmpPacker.YUV) { //YUV 裸流資料
            mPusherManager.pushYUV(data);
        }
    }

    /**
     * 傳送 H264 資料
     *
     * @param h264
     */
    public native void pushH264(byte[] h264, int type, long timeStamp);
    /**
     * @param audio     直接推編碼完成之後的音訊流
     * @param length
     * @param timestamp
     */
    public native void pushAACData(byte[] audio, int length, int timestamp);
    /**
     * 傳送 PCM 原始資料
     *
     * @param audioData
     */
    public native void native_pushAudio(byte[] audioData);
    /**
     * push 視訊原始 nv21
     *
     * @param data
     */
    public native void native_push_video(byte[] data);
複製程式碼

1. Rtmp 連結

Rtmp 底層是 TCP 協議,所以你可以使用 Java Socket 進行連線,也可以使用 c++ librtmp 庫來進行連線,我們們這裡就使用 librtmp 來進行連線。

/**
 * 真正 rtmp 連線的函式
 */
void RTMPModel::onConnect() {
		...

    //1. 初始化
    RTMP_Init(rtmp);
    //2. 設定rtmp地址
    int ret = RTMP_SetupURL(rtmp, this->url)

  	//3. 確認寫入 rtmp
    RTMP_EnableWrite(rtmp);
		//4. 開始連結
    ret = RTMP_Connect(rtmp, 0);
		//5. 連線成功之後需要連線一個流
    ret = RTMP_ConnectStream(rtmp, 0);
   
  ...

}
複製程式碼

2. Native 音訊模組接收 AAC Flv 打包資料

/**
 * 直接推送 AAC 硬編碼
 * @param data
 */
void AudioEncoderChannel::pushAAC(u_char *data, int dataLen, long timestamp) {
    RTMPPacket *packet = (RTMPPacket *) malloc(sizeof(RTMPPacket));
    RTMPPacket_Alloc(packet, dataLen);
    RTMPPacket_Reset(packet);
    packet->m_nChannel = 0x05; //音訊
    memcpy(packet->m_body, data, dataLen);
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_hasAbsTimestamp = FALSE;
    packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
    packet->m_nBodySize = dataLen;
    if (mAudioCallback)
        mAudioCallback(packet); //傳送給 rtmp 模組
}
複製程式碼

3. Native 視訊模組接收 H264 Flv 打包資料

/**
 *
 * @param type  視訊幀型別
 * @param buf  H264
 * @param len H264 長度
 */
void VideoEncoderChannel::sendH264(int type, uint8_t *data, int dataLen, int timeStamp) {
    RTMPPacket *packet = (RTMPPacket *) malloc(sizeof(RTMPPacket));
    RTMPPacket_Alloc(packet, dataLen);
    RTMPPacket_Reset(packet);

    packet->m_nChannel = 0x04; //視訊

    if (type == RTMP_PACKET_KEY_FRAME) {
        LOGE("視訊關鍵幀");
    }
    memcpy(packet->m_body, data, dataLen);
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_hasAbsTimestamp = FALSE;
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    packet->m_nBodySize = dataLen;
    mVideoCallback(packet);//傳送給 rtmp 模組
}
複製程式碼

4. RTMP 傳送資料

4.1 將接收到的資料入傳送佇列

//不管是軟編碼還是硬編碼所有傳送資料都需要入佇列
void callback(RTMPPacket *packet) {
    if (packet) {
        if (rtmpModel) {
            //設定時間戳
            packet->m_nTimeStamp = RTMP_GetTime() - rtmpModel->mStartTime;
            rtmpModel->mPackets.push(packet);
        }
    }
}
複製程式碼

4.2 傳送

/**
 * 真正推流的地方
 */
void RTMPModel::onPush() {
    RTMPPacket *packet = 0;
    while (isStart) {
      	//從佇列中獲取傳送的音視訊資料
        mPackets.pop(packet);
        if (!readyPushing) {
            releasePackets(packet);
            return;
        }
        if (!packet) {
            LOGE("獲取失敗");
            continue;
        }
        packet->m_nInfoField2 = rtmp->m_stream_id;
        int ret = RTMP_SendPacket(rtmp, packet, 1);
        if (!ret) {
            LOGE("傳送失敗")
            if (pushCallback) {
                pushCallback->onError(THREAD_CHILD, RTMP_PUSHER_ERROR);
            }
            return;
        }
    }
    releasePackets(packet);
    release();//釋放
}
複製程式碼

5. 關閉 RTMP

當不需要傳送音視訊資料的時候需要關閉 rtmp 連線

void RTMPModel::release() {
    isStart = false;
    readyPushing = false;
    if (rtmp) {
        RTMP_DeleteStream(rtmp);
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
        rtmp = 0;
        LOGE("釋放 native 資源");
    }
    mPackets.clearQueue();
}
複製程式碼

簡單談談軟硬編解碼

1. 區別

軟編碼: 使用 CPU 進行編碼。 硬編碼: 使用 GPU 進行編碼。

2. 比較

軟編碼: 實現直接、簡單,引數調整方便,升級容易,但 CPU 負載重,效能較硬編碼低,低位元速率下質量通常比硬編碼要好一點。 硬編碼: 效能高,低位元速率下通常質量低於軟編碼器,但部分產品在 GPU 硬體平臺移植了優秀的軟編碼演算法(如X264)的,質量基本等同於軟編碼。

3. 使用場景

軟編碼: 適用短時間操作,如錄製短視訊等。

硬編碼: 長時間編碼或者對視訊質量要求高(VOIP 實時通話),可以推薦硬體編碼 (前提是手機效能好)。

總結

到這裡 Android 端軟編推流,硬編推流都分別實現了。在專案上可以根據實際情況來選擇到底是硬編還是軟編。

硬編我是基於來瘋開源專案進行二次開發:

Android 推流專案地址

Android 拉流專案地址

參考

相關文章