美文网首页安卓资料汇总1-Android开发知识音视频
当一个安卓开发玩抖音玩疯了之后(一)

当一个安卓开发玩抖音玩疯了之后(一)

作者: Mr_villain | 来源:发表于2018-04-12 19:58 被阅读1183次

    滴,滴滴!

    暗号

    本篇文章将介绍自己总结的短视频录制的相关内容,主要分为三个部分:

    • 摄像头内容录制
    • 音频录制
    • 视频合成

    先上效果图

    • 录制过程


      录制过程截图
    • 录制结果


      录制结果截图

    1.摄像头内容录制

    录制

    录制流程大致如上图所示。

    渲染关键代码

    新建外部纹理

        @Override
        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            mTextureId = GLUtils.createTextureObject(GLES11Ext.GL_TEXTURE_EXTERNAL_OES);
            mSurfaceTexture = new SurfaceTexture(mTextureId);
            ...
        }
    

    新建了外部纹理之后,传入 Camera

    mCamera.setPreviewTexture(mSurfaceTexture);
    mCamera.startPreview();
    

    GLSurfaceView 渲染时,请求 SurfaceTexture 更新,获取最新的内容

        @Override
        public void onDrawFrame(GL10 gl) {
            if (mFilter == null) {
                return;
            }
            float matrix[] = new float[16];
            if (mSurfaceTexture != null) {
                //请求刷新最新内容
                mSurfaceTexture.updateTexImage();
            }
            mSurfaceTexture.getTransformMatrix(matrix);
    
            if (mFrameListener != null) {
                //通知MediaCodec刷新画面
                mFrameListener.onFrameAvailable(new VideoFrameData(mFilter,
                        matrix, mSurfaceTexture.getTimestamp(), mTextureId));
            }
            mFilter.init();
            if (mOldFilter != null) {
                mOldFilter.release();
                mOldFilter = null;
            }
            mSurfaceTexture.getTransformMatrix(mMatrix);
            //绘制预览内容
            mFilter.draw(mTextureId, mMatrix);
        }
    

    mFilter 中包含 OpenGL 相关的着色器程序

    着色器代码如下:

        /**
         * 默认代码
         */
        private static final String FRAGMENT_CODE =
                "#extension GL_OES_EGL_image_external : require\n" +
                        "precision mediump float;\n" +
                        "varying vec2 vTextureCoord;\n" +
                        "uniform samplerExternalOES uTexture;\n" +
                        "void main() {\n" +
                        "    gl_FragColor = texture2D(uTexture, vTextureCoord);\n" +
                        "}\n";
        /**
         * 默认代码
         */
        private static final String VERTEX_CODE =
                "uniform mat4 uTexMatrix;\n" +
                        "attribute vec2 aPosition;\n" +
                        "attribute vec4 aTextureCoord;\n" +
                        "varying vec2 vTextureCoord;\n" +
                        "void main() {\n" +
                        "    gl_Position = vec4(aPosition,0.0,1.0);\n" +
                        "    vTextureCoord = (uTexMatrix * aTextureCoord).xy;\n" +
                        "}\n";
    

    外部纹理和普通纹理不同,需要在片段着色器代码头部声明拓展。

    #extension GL_OES_EGL_image_external : require
    

    着色器代码比较简单,不包含滤镜相关的内容,直接使用相机的纹理绘制一个矩形。

    录制关键代码

    内容录制编码使用 MediaCodec + MediaMuxer 的组合来实现。MediaCodec 在初始化时,我们可以从中获取一个 Surface,用来往里面填充内容。

            MediaFormat format = MediaFormat.createVideoFormat(C.VideoParams.MIME_TYPE,
                    configuration.getVideoWidth(),
                    configuration.getVideoHeight());
            //设置参数
            format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                    MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
            format.setInteger(MediaFormat.KEY_BIT_RATE, C.VideoParams.BIT_RATE);
            format.setInteger(MediaFormat.KEY_FRAME_RATE, C.VideoParams.SAMPLE_RATE);
            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, C.VideoParams.I_FRAME_INTERVAL);
            MediaCodec encoder = MediaCodec.createEncoderByType(C.VideoParams.MIME_TYPE);
            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            inputSurface = encoder.createInputSurface();
    

    获取 inputSurface 之后,我们新建一个 EGLSurface,到这里编码器的初始化就完成了,当有新的内容时,通知编码器来刷新。之前我们获取了GLSurfaceView 的 GL 上下文,当收到新内容通知时,我们把 GL 环境切到编码器的线程,然后绘制,最后调用 swapBuffers 方法把绘制的内容填充到inputSurface 中,这就是所谓的离屏渲染(听着很高大上,后面讲解短视频后期制作时也会用到这个)。

    这里不使用 EOS 纹理也是可以的,我们可以通过 Camera 的setPreviewCallback 方法监听相机的每一帧数据,然后将 YUV 数据转换成ARGB 数据,再转成纹理交给 OpenGL 渲染即可。

    最后新建 MediaMuxer

    muxer = new MediaMuxer(configuration.getFileName(),
                    MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    

    此部分内容参考 grafika 实现

    微笑

    视频变速

    视频变速相对来说比较容易,在编码之后,我们从 MediaCodec 的缓冲区中获取本次编码内容的 ByteBuffer 和 BufferInfo ,前者是编码后的内容,后者是本次内容的信息,包括时间戳,大小等。我们通过改变视频的时间戳,就可以达到视频变速的要求。比如要加快视频的速度,那么只需要将视频的时间戳间隔缩小一定的倍数即可。放慢操作和这个相反,只需要把时间戳间隔放大一定的倍数即可。

    音频录制

    音频的录制我们需要使用到 AudioRecord 这个大杀器,大致流程图如下。

    音频流程图

    音频录制比较简单,参考官方文档即可。这里需要开启两条线程,因为目前使用的编码是同步模式,如果是在一条线程里处理数据,会导致麦克风的数据丢失。

    关键代码如下:

    初始化AudioRecord
    指定单声道模式,采样率为 44100,每个采样点 16 比特

     int bufferSize = AudioRecord.getMinBufferSize(
                    configuration.getSampleRate(), C.AudioParams.CHANNEL,
                    C.AudioParams.BITS_PER_SAMPLE);
     recorder = new AudioRecord(
                    MediaRecorder.AudioSource.MIC, configuration.getSampleRate(),
                    C.AudioParams.CHANNEL, C.AudioParams.BITS_PER_SAMPLE, bufferSize);
    

    初始化MediaCodec

            MediaFormat audioFormat = MediaFormat.createAudioFormat(C.AudioParams.MIME_TYPE,
                    C.AudioParams.SAMPLE_RATE, C.AudioParams.CHANNEL_COUNT);
            audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE,
                    MediaCodecInfo.CodecProfileLevel.AACObjectLC);
            audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, C.AudioParams.CHANNEL);
            audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, C.AudioParams.BIT_RATE);
            audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, C.AudioParams.CHANNEL_COUNT);
            audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024 * 4);
            encoder = MediaCodec.createEncoderByType(C.AudioParams.MIME_TYPE);
            encoder.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            bufferInfo = new MediaCodec.BufferInfo();
            mStream = new BufferedOutputStream(new FileOutputStream(configuration.getFileName()));
    

    音频编码

    读取音频数据

     byte[] buffer = new byte[configuration.getSamplePerFrame()];
     int bytes = recorder.read(buffer, 0, buffer.length);
     if (bytes > 0) {
         encode(buffer, bytes);
     }
    

    塞进MediaCodec缓冲区

        private void onEncode(byte[] data, int length) {
            final ByteBuffer[] inputBuffers = encoder.getInputBuffers();
            while (true) {
                final int inputBufferIndex = encoder.dequeueInputBuffer(BUFFER_TIME_OUT);
                if (inputBufferIndex >= 0) {
                    final ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
                    inputBuffer.clear();
                    inputBuffer.position(0);
                    if (data != null) {
                        inputBuffer.put(data, 0, length);
                    }
                    if (length <= 0) {
                        encoder.queueInputBuffer(inputBufferIndex, 0, 0,
                                getTimeUs(), MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                        break;
                    } else {
                        encoder.queueInputBuffer(inputBufferIndex, 0, length,
                                getTimeUs(), 0);
                    }
                    break;
                }
            }
        }
    

    取出编码后的数据并写入文件

        private void drain() {
            bufferInfo = new MediaCodec.BufferInfo();
            ByteBuffer[] encoderOutputBuffers = encoder.getOutputBuffers();
            int encoderStatus = encoder.dequeueOutputBuffer(bufferInfo, C.BUFFER_TIME_OUT);
            while (encoderStatus >= 0) {
                ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
                int outSize = bufferInfo.size;
                encodedData.position(bufferInfo.offset);
                encodedData.limit(bufferInfo.offset + bufferInfo.size);
                byte[] data = new byte[outSize + 7];
                addADTSHeader(data, outSize + 7);
                encodedData.get(data, 7, outSize);
                try {
                    mStream.write(data, 0, data.length);
                } catch (IOException e) {
                    LogUtil.e(e);
                }
                if (duration >= configuration.getMaxDuration()) {
                    stop();
                }
                encoder.releaseOutputBuffer(encoderStatus, false);
                encoderStatus = encoder.dequeueOutputBuffer(bufferInfo, C.BUFFER_TIME_OUT);
            }
        }
    

    aac文件对内容格式有要求,需要在每一帧的内容头部添加内容,代码如下:

        private void addADTSHeader(byte[] packet, int length) {
            int profile = 2; // AAC LC
            int freqIdx = 4; // 44.1KHz
            int chanCfg = 1; // CPE
            // fill in A D T S data
            packet[0] = (byte) 0xFF;
            packet[1] = (byte) 0xF9;
            packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2));
            packet[3] = (byte) (((chanCfg & 3) << 6) + (length >> 11));
            packet[4] = (byte) ((length & 0x7FF) >> 3);
            packet[5] = (byte) (((length & 7) << 5) + 0x1F);
            packet[6] = (byte) 0xFC;
        }
    

    音频变速

    一开始调研短视频方案的时候,对于音频变速这方面,想了很多个方案:

    • 音频和视频使用 MediaMuxer 合成,指定变速速率,在录制结束时使用ffmpeg 进行变速
    • 视频和音频分开录制,视频实时变速录制,音频在录制结束时使用 ffmpeg 变速,然后再使用 ffmpeg 合并到视频中
    • 音频和视频分开录制,音频实时变速,视频实时变速,录制完成后,使用ffmpeg 合成

    最终我选择了第三个方案,前两个方案的死因如下:

    • 效率差,ffmpeg 如果要对视频进行变速,效率很低,一个视频如果要放慢三倍,最久的时候要十几秒,并且因为使用的是软编,对 cpu 占用率比较高,会导致 UI 卡顿,
    • 音频变速耗时比视频变速要少,但是对用户来说,还是可以感知的到的,所以这个方案也 pass。(主要是达不到抖音的效果)

    第三个方案需要使用一个第三方库——SoundTouch,它可以改变音频的音调和速度。SoundTouch 由 C++ 实现,因此我们需要用 NDK 工具把它集成到工程当中。集成的方法参照官方文档即可。官方的例子中主要给出了处理 wav 文件的方法,接下来我介绍一下如何使用这个库实时处理 pcm 数据(通过实时处理PCM 数据,我们还可以弄个变声功能噢)。

    SoundTouch 使用

    新建类—— SoundTouch

    public class SoundTouch {
        private native final void setTempo(long handle, float tempo);
    
        private native final void setPitchSemiTones(long handle, float pitch);
    
        private native final void putBytes(long handle, byte[] input, int offset, int length);
    
        private native final int getBytes(long handle, byte[] output, int length);
    
        private native final static long newInstance();
    
        private native final void deleteInstance(long handle);
    
        private native final void flush(long handle);
    
        private long handle = 0;
    
        public SoundTouch() {
            handle = newInstance();
        }
    
        public void putBytes(byte[] input) {
            this.putBytes(handle, input, 0, input.length);
        }
    
        public int getBytes(byte[] output) {
            return this.getBytes(handle, output, output.length);
        }
    
    
        public void close() {
            deleteInstance(handle);
            handle = 0;
        }
    
        public void flush() {
            this.flush(handle);
        }
    
        public void setTempo(float tempo) {
            setTempo(handle, tempo);
        }
    
    
        public void setPitchSemiTones(float pitch) {
            setPitchSemiTones(handle, pitch);
        }
    
        static {
            System.loadLibrary("soundtouch");
        }
    
    }
    

    主要有四个方法

    • setTempo —— 设置音频变速 大于1为加速,小于1为减速
    • setPitchSemiTones —— 设置音频声调
    • putBytes —— 将 pcm 数据添加到 SoundTouch 管道中
    • getBytes —— 从 SoundTouch 管道中取出处理过的 pcm 数据

    新建对应的 cpp 文件,关键代码如下:

    void Java_com_netease_soundtouch_SoundTouch_setTempo(JNIEnv *env, jobject thiz, jlong handle, jfloat tempo)
    {
        SoundTouch *ptr = (SoundTouch *)handle;
        ptr->setTempo(tempo);
    }
    void Java_com_netease_soundtouch_SoundTouch_setPitchSemiTones(JNIEnv *env, jobject thiz, jlong handle, jfloat pitch)
    {
        SoundTouch *ptr = (SoundTouch *)handle;
        ptr->setPitchSemiTones(pitch);
    }
    void Java_com_netease_soundtouch_SoundTouch_putBytes(JNIEnv *env, jobject thiz, jlong handle, jbyteArray input, jint offset, jint length)
    {
        SoundTouch *soundTouch = (SoundTouch *)handle;
        jbyte *data;
        data = env->GetByteArrayElements(input, JNI_FALSE);
        soundTouch->putSamples((SAMPLETYPE *)data, length/2);
        env->ReleaseByteArrayElements(input, data, 0);
    }
    jint Java_com_netease_soundtouch_SoundTouch_getBytes(JNIEnv *env, jobject thiz, jlong handle, jbyteArray output, jint length)
    {
        int receiveSamples = 0;
        int maxReceiveSamples = length/2;
        SoundTouch *soundTouch = (SoundTouch *)handle;
        jbyte *data;
        data = env->GetByteArrayElements(output, JNI_FALSE);
        receiveSamples = soundTouch->receiveSamples((SAMPLETYPE *)data,
                                                    maxReceiveSamples);
        env->ReleaseByteArrayElements(output, data, 0);
        return receiveSamples;
    }
    

    处理 pcm 数据

        //在将pcm导入MediaCodec之前,先由SoundTouch处理一遍
        private void encode(final byte[] data, final int length) {
            encodeHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (soundTouch != null) {
                        soundTouch.putBytes(data);
                        while (true) {
                            //如果是用MediaMuxer来生成音频,我们每次只能写入一帧数据,那么这里缓冲区就不能用4096,只能用1024
                            byte[] modified = new byte[4096];
                            int count = soundTouch.getBytes(modified);
                            if (count > 0) {
                                onEncode(modified, count * 2);
                                drain();
                            } else {
                                break;
                            }
                        }
                    } else {
                        onEncode(data, length);
                        drain();
                    }
                }
            });
        }
    

    音频和视频合成

    录制完视频和音频之后,我们需要将音频和视频进行合成,这一步直接使用FFMPEG 工具即可,命令行如下:

    ffmpeg -y -i audioFile -ss 0 -t duration -i videoFile -acodec copy -vcodec copy output

    其中,audioFile 为我们的 aac 文件的路径,videoFile 为 mp4 文件的路径,output 为最终生成的 mp4 文件的路径,duration 为音频文件的长度,使用MediaExtractor 获取即可。

    ffmpeg 不会自动帮我们创建文件,在合成之前,需要先创建output文件

    执行完这个命令后,音频和视频就合成完毕了,15秒的视频,合成一次大概只需要100ms左右。我们只需要在每小段视频录制完毕时合成一次即可,对用户来说没什么影响。视频的码率越高,合成所需要的时间越久。

    视频合成

    多段视频拼接使用 ffmpeg 即可,无需重新解码,我们在点击 app 中的下一步按钮时进行视频的拼接。关键代码如下:

        public static VideoCommand mergeVideo(List<String> videos, String output) {
            String appDir = StorageUtil.getExternalStoragePath() + File.separator;
            String fileName = "ffmpeg_concat.txt";
            FileUtils.writeTxtToFile(videos, appDir, fileName);
            VideoCommand cmd = new VideoCommand();
            cmd.append("ffmpeg").append("-y").append("-f").append("concat").append("-safe")
                    .append("0").append("-i").append(appDir + fileName)
                    .append("-c").append("copy").append(output);
            return cmd;
        }
    

    命令行为:

    ffmpeg -y -f concat -safe 0 -i concatFile -c copy output

    其中,concatFile 是一个 txt 文件,内容为我们要拼接的文件的路径列表,output 为最终输出的 mp4 文件。

    总结

    整个短视频的录制方案大概就是如此,关于视频录制方面,因为没有具体线上项目实践过,所以可能会存在机型不兼容的情况,大家如果有更好的方案,欢迎在评论区提出来噢,一起探讨下。有些地方讲解不对或者觉得不清楚的,欢迎大家在评论区指出。后面会发关于短视频后期处理的文章,敬请关注!

    滴,滴滴!

    参考资料

    1.MediaCodec - Android Developer
    2.AudioRecord - Android Developer
    3.SoundTouch Audio Processing Library
    4.FFMPEG —— A complete, cross-platform solution to record, convert and stream audio and video.
    5.Google-Grafika
    6.MP4音视频同步原理

    相关文章

      网友评论

      本文标题:当一个安卓开发玩抖音玩疯了之后(一)

      本文链接:https://www.haomeiwen.com/subject/ilcfkftx.html