Android 音視訊錄製硬編碼實現

土豆兒發表於2018-09-18

Camera預覽

目前 Android Camera 有兩個版本,分別是Camera 和 Camera2,Camera2 是從 5.0開始引入的,但是由於相容性問題且很多手機廠商的支援程度比較弱,所以目前還是使用 Camera。 ​ Camera 的預覽,先定義了一個 Camera 介面。

interface CameraInterface {
    fun openCamera()
    fun openCamera(cameraId : Int)
    fun releaseCamera()
    fun switchCamera(surface: SurfaceTexture)
    fun setPreviewDisplay(surface: SurfaceTexture)
    fun switchCamera(holder: SurfaceHolder)
    fun setPreviewDisplay(holder: SurfaceHolder)
}
複製程式碼

要將Camera所捕獲的資料渲染在螢幕上,需要有一個承載的地方。所以這個承載的地方可以是SurfaceView、TextureView和GLSurfaceView,本文使用的是GLSurfaceView。 ​ 既然是視訊那肯定是需要預覽的,從Camera提供的方法來看,能獲取到預覽資料的方式有兩種,一種是SurfaceHolder,另一種是SurfaceTexture。本文使用的是GLSurfaceView,那麼在此類裡面就只能使用SurfaceTexture了。

所以這兩個類有什麼不一樣呢?這裡後面再講。

最後,OpenGLES 也是必不可少的。 ​ 使用OpenGLES的話,分三步。 ​ 第一步需要先定義一個頂點著色器和一個片段著色器的GLSL。 ​ 第二步建立一個頂點著色器物件和一個片段著色器物件,再將各自的glsl程式碼連線到著色器物件上,再編譯著色器物件。 ​ 第三步建立一個程式物件,將編譯好的著色器物件連線到程式物件上,最後再連線程式物件。 ​ 下面是具體的程式碼。

public static int genProgram(final String strVSource, final String strFSource) {
        int iVShader;
        int iFShader;
        int iProgId;
        int[] link = new int[1];
        iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER);
        if (iVShader == 0) {
            Log.d("Load Program", "Vertex Shader Failed");
            return 0;
        }
        iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER);
        if (iFShader == 0) {
            Log.d("Load Program", "Fragment Shader Failed");
            return 0;
        }

        iProgId = GLES20.glCreateProgram();
        GLES20.glAttachShader(iProgId, iVShader);
        GLES20.glAttachShader(iProgId, iFShader);
        GLES20.glLinkProgram(iProgId);
        GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0);
        if (link[0] <= 0) {
            Log.d("Load Program", "Linking Failed");
            return 0;
        }
        GLES20.glDeleteShader(iVShader);
        GLES20.glDeleteShader(iFShader);
        return iProgId;
}
複製程式碼
private static int loadShader(final String strSource, final int iType) {
        int[] compiled = new int[1];
        int iShader = GLES20.glCreateShader(iType);
        GLES20.glShaderSource(iShader, strSource);
        GLES20.glCompileShader(iShader);
        GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0);
        if (compiled[0] == 0) {
            Log.e("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader));
            return 0;
        }
        return iShader;
}
複製程式碼

建立好了programId之後,就可以根據id來獲取著色器中的屬性

fun init() {

        mProgramId = OpenGlUtils.genProgram(VERTEX_SHADER, FRAGMENT_SHADER_EXT)
        if (mProgramId <= 0) {
            throw RuntimeException("Unable to create program")
        }

        maPositionLoc = OpenGlUtils.glGetAttribLocation(mProgramId, "aPosition")
        OpenGlUtils.checkLocation(maPositionLoc, "aPosition")
        maTextureCoordLoc = OpenGlUtils.glGetAttribLocation(mProgramId, "aTextureCoord")
        OpenGlUtils.checkLocation(maTextureCoordLoc, "aTextureCoord")

        muMVPMatrixLoc = OpenGlUtils.glGetUniformLocation(mProgramId, "uMVPMatrix")
        OpenGlUtils.checkLocation(muMVPMatrixLoc, "uMVPMatrix")
        muTexMatrixLoc = OpenGlUtils.glGetUniformLocation(mProgramId, "uTexMatrix")
        OpenGlUtils.checkLocation(muTexMatrixLoc, "uTexMatrix")
}
複製程式碼

接下來到SurfaceTexture,在使用之前,需要先建立一個紋理id

public static int getExternalOESTextureID(){
		int[] texture = new int[1];
		GLES20.glGenTextures(1, texture, 0);
		GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texture[0]);
		GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
				GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR);
		GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);
		return texture[0];
}
複製程式碼

然後根據生成的紋理id建立一個 SurfaceTexture 的例項,開啟Camera,將SurfaceTexture設定為Camera的承載。

mTextureId = OpenGlUtils.getExternalOESTextureID()
mSurfaceTexture = SurfaceTexture(mTextureId)     mSurfaceTexture.setOnFrameAvailableListener(onFrameAvailableListener)
mCameraProxy = CameraProxy(CameraCapture())
mCameraProxy.openCamera()
mCameraProxy.setPreviewDisplay(mSurfaceTexture)
複製程式碼

OnFrameAvailableListener 的定義如下,這裡跟GLSurfaceView所啟用的重新整理模式有關係。GLSurfaceView.RENDERMODE_CONTINUOUSLY 這個是自動重新整理,GLSurfaceView.RENDERMODE_WHEN_DIRTY 這個是通過底層的訊息通知上來,讓GLSurfaceView 呼叫重新整理。在本文中使用的是GLSurfaceView.RENDERMODE_WHEN_DIRTY,網上的說法是降低cpu 負載,我去測試了一下,使用GLSurfaceView.RENDERMODE_CONTINUOUSLY 的時候,cpu佔有率確實高不少。

private val onFrameAvailableListener = SurfaceTexture.OnFrameAvailableListener {
        Log.i("GLSurfaceView_History","requestRender")
        requestRender()
}
複製程式碼

最後就是onDrawFrame的過程了

override fun onDrawFrame(gl: GL10?) {
    Log.i("GLSurfaceView_History","onDrawFrame")
    mSurfaceTexture.updateTexImage()
    ...
    mSurfaceTexture.getTransformMatrix(mSTMatrix)
    mFilter.drawFrame(mTextureId, mSTMatrix)
}
複製程式碼

全部原始碼可以看github,地址在文章末尾。 關於預覽角度的問題,從 Camera 出來的預覽影象的角度是向右的,所以這裡有兩個方式處理角度問題。第一個是通過 camera.setDisplayOrientation 可以設定預覽角度。第二個是通過紋理座標。

視訊資料獲取

從上面的onDrawFrame可以看到,視訊幀的獲取是通過SurfaceTexture的getTransformMatrix方法獲取的。但是這裡的獲取方式,需要通過和編碼器結合起來。

MediaCodec 有兩種視訊資料的獲取方式,第一是使用輸出Surface建立另一個繪圖表面,第二就是使用ByteBuffer。在使用輸出Surface的情況下,則需要建立一個EGLContext和一個新的執行緒繫結起來,再將視訊資料繪製到此Surface上,而在此執行緒上也要重新初始化頂點著色器和片段著色器相關的東西。

建立另一個繪圖表面需要使用到EGL,首先獲取到當前GLSurface 的 EGLContext,主要的作用是能夠和新建立的EGLContext 共享著色器和紋理。

其實在GLSurfaceView 內部也有EGL的一個建立過程,主要需要呼叫六個方法,而建立一個新的繪圖表面與GLSurfaceView內部的建立過程一致,但是唯一不同的地方就是新的繪圖表面在建立EGLContext 的時候與當前的GLSurfaceView共享資料。

1.eglGetDisPlay

2.eglInitialize

3.eglChooseConfig

4.eglCreateContext

5.createWindowSurface

6.eglMakeCurrent

這裡需要建立一個新的繪製執行緒,createWindowSurface的作用是建立一個渲染區域,其中有個引數是MediaCodec提供的Surface,最後MakeCurrent的作用是將EGLContext 與當前執行緒繫結到一起,使此執行緒可以繪製。具體原始碼在EglCore類裡面。

然後在onDrawFrame裡面將資料通過Handler傳送到此執行緒裡面,再去做的繪製到此Surface上。

音訊資料獲取

AudioRecord 大家都會用了,那開始錄音之後的程式碼是這樣的

while (mIsRecording) {
    readSize = mAudioRecord.read(tempBuffer, 0, mBufferSize);
    if (readSize == AudioRecord.ERROR_INVALID_OPERATION || readSize == AudioRecord.ERROR_BAD_VALUE) {
        continue;
    }
    if (readSize > 0) {
        //獲取輸入buffer的index 內部有同步機制
        mAudioInputBufferIndex = mAudioEncoder.getCodec().dequeueInputBuffer(-1);
        if (mAudioInputBufferIndex >= 0) {
            ByteBuffer inputBuffer = mAudioEncoder.getCodec().getInputBuffer(mAudioInputBufferIndex);
            if (inputBuffer != null) {
                inputBuffer.put(tempBuffer);
                audioAbsolutePtsUs = (System.nanoTime()) / 1000L;
                //壓入編碼棧中
                mAudioEncoder.getCodec().queueInputBuffer(mAudioInputBufferIndex, 0, mBufferSize, audioAbsolutePtsUs, 0);
            }
        }
        //通知編碼執行緒獲取資料
        mAudioEncoder.frameAvailable();
    }
}
複製程式碼

音訊資料壓入棧後,通知編碼執行緒做處理,就是這麼個過程。假如視訊資料不是通過surface獲取的,那步驟也和這裡一致。

編碼

MediaCodec 有三種編碼方式,分別是 4.1版本的ByteBuffer的同步方式、5.0版本的同步方式和5.0版本的非同步方式,目前我這邊使用的是5.0版本的同步方式。

無論是視訊還是音訊,編碼的方式都是一樣的,只是建立的MediaCodec的配置資訊不一樣。所以在此,我定義了一個抽象的編碼基類。

下面是具體的獲取編碼後資料的程式碼。

private void drainEncoder(boolean endOfStream) {
    final int TIMEOUT_USEC = 10000;
    String className = this.getClass().getName();
    if (endOfStream) {
        if (isSurfaceInput()) {
            //這個是視訊編碼結束的標誌。
            mEncoder.signalEndOfInputStream();
            Log.i("lock_thread", "video_end");
        }
    }

    while (true) {
        int outputBufferId = mEncoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
        if (outputBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
            if (!endOfStream) {
                break;
            }
        } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
            //當資料開始queue回首先跑到這裡,給muxer新增一個通道。且開始合併。
            MediaFormat newFormat = mEncoder.getOutputFormat();
            mTrackIndex = mMuxer.addTrack(newFormat);
            mMuxer.start();
        } else if (outputBufferId < 0) {
        } else {
            //這裡是可以真正拿到編碼後資料的地方。
            ByteBuffer outputBuffer = mEncoder.getOutputBuffer(outputBufferId);
            if (mBufferInfo.size != 0 && outputBuffer != null) {
                outputBuffer.position(mBufferInfo.offset);
                outputBuffer.limit(mBufferInfo.offset + mBufferInfo.size);
                //pts 必須得設定,否則會muxer.stop時丟擲異常。
                mBufferInfo.presentationTimeUs = getPTSUs();
                mMuxer.writeSampleData(mTrackIndex, outputBuffer, mBufferInfo);
                prevOutputPTSUs = mBufferInfo.presentationTimeUs;
            }
            mEncoder.releaseOutputBuffer(outputBufferId, false);
            if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                Log.i("lock_thread", Thread.currentThread().getName() + "_thread_end");
                break;
            }
        }
    }
}
複製程式碼

我從google的編碼demo中,看到的是在繪製執行緒中直接編碼,這個方式單通道編碼出來的檔案是沒問題的,無論是視訊還是音訊。但是雙通道結合到一起,編碼出來的檔案就有問題了。所以我換了一個方式去處理這個編碼問題,就是增加一個執行緒去獲取編碼後的資料寫入muxer。

先是在基類實現一個runable,run 方法如下所示

@Override
public void run() {
    processWait();
    while (mIsCapture) {


        drainEncoder(false);

        processWait();

        if (mIsEndOfStream) {

            drainEncoder(true);

            release();
            releaseMuxer();
        }
    }
}
複製程式碼

當進入這個執行緒時先wait,等待我有資料寫入到encoder 中了,在notify,讓這個執行緒繼續走下去。所以什麼時候通知編碼執行緒繼續往下走呢。當然是視訊和音訊有資料寫入的時候咯。

封裝

public WrapMuxer(String outputFile, int tracks) {
    try {
        mMuxer = new MediaMuxer(outputFile, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    } catch (IOException e) {
        e.printStackTrace();
    }

    mMaxTracks = tracks;
}
複製程式碼

定義了一個 WrapMuxer的類去做封裝處理,從上面程式碼中,無論是視訊還是音訊,都需要addTrack 然後再 start

所以我在這個類中做了個處理。

public synchronized int addTrack(MediaFormat format) {
    mCurrentTracks++;
    return mMuxer.addTrack(format);
}

public synchronized void start() {
    if (isLoadAllTrack() && !mMuxerStarted) {
        mMuxer.start();
        mMuxerStarted = true;
    }
}
複製程式碼

最後release

public void release() {
    mCurrentTracks--;
    if (mCurrentTracks == 0) {
        if (mMuxer != null) {
            // TODO: stop() throws an exception if you haven't fed it any data.  Keep track
            //       of frames submitted, and don't call stop() if we haven't written anything.
            mMuxer.stop();
            mMuxer.release();
            mMuxer = null;
        }

        mMuxerStarted = false;
    }
}
複製程式碼

最後muxer過程中需要注意一個地方是當沒有任何資料通過mMuxer.writeSampleData寫入時,最後stop 必定拋異常。

關鍵屬性

MediaCodec.INFO_TRY_AGAIN_LATER // 
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED // 
複製程式碼

引用

github.com/google/graf…

原始碼

github.com/latnok-nort…

相關文章