设为首页收藏本站

安徽论坛

 找回密码
 立即注册

QQ登录

只需一步,快速开始

查看: 50316|回复: 0

Android 音视频编解码(二) -- MediaCodec 解码(同步和异步)

[复制链接]

76

主题

0

回帖

240

积分

中级会员

Rank: 3Rank: 3

积分
240
发表于 2022-3-26 10:30:20 | 显示全部楼层 |阅读模式
网站内容均来自网络,本站只提供信息平台,如有侵权请联系删除,谢谢!
音视频 系列文章
Android 音视频开发基础
Android 音视频编解码(一) – MediaCodec 初探
音视频工程
  上面的文章Android 音视频编解码(一) – MediaCodec 初探 中,已经对 MediaCodec 有个感性的认知,这一章,来学习 MediaCodec 的解码功能。
本章效果如下:

一、同步解码

为了更好的理解 MediaCodec 的工作原理和工作步骤,首先先使用同步解码的方式去解码本地视频。
1.1 拿到视频参数

首先,我们先要准备一个视频,比如 MP4 格式的,它其实已经被编码过的,我们可以通过 MediaCodec 来获取该视频的 MediaFormat 信息,如果你对 MediaExtractor 不熟悉,可以通过 Android 音视频开发(五) – 使用 MediaExtractor 分离音视频,并使用 MediaMuxer合成新视频(音视频同步) 来学习;
定义一个 MyExtractor ,里面实现 MediaExtractor 用来专门解析视频,拿到视频数据:
  1.     public MyExtractor(String path) {        try {            mediaExtractor = new MediaExtractor();            // 设置数据源            mediaExtractor.setDataSource(path);        } catch (IOException e) {            e.printStackTrace();        }        //拿到所有的轨道        int count = mediaExtractor.getTrackCount();        for (int i = 0; i < count; i++) {            //根据下标拿到 MediaFormat            MediaFormat format = mediaExtractor.getTrackFormat(i);            //拿到 mime 类型            String mime = format.getString(MediaFormat.KEY_MIME);            //拿到视频轨            if (mime.startsWith("video")) {                videoTrackId = i;                videoFormat = format;            } else if (mime.startsWith("audio")) {                //拿到音频轨                audioTrackId = i;                audioFormat = format;            }        }    }    public void selectTrack(int trackId){        mediaExtractor.selectTrack(trackId);    }    /**     * 读取一帧的数据     *     * @param buffer     * @return     */    public int readBuffer(ByteBuffer buffer) {        //先清空数据        buffer.clear();        //选择要解析的轨道      //  mediaExtractor.selectTrack(video ? videoTrackId : audioTrackId);        //读取当前帧的数据        int buffercount = mediaExtractor.readSampleData(buffer, 0);        if (buffercount < 0) {            return -1;        }        //记录当前时间戳        curSampleTime = mediaExtractor.getSampleTime();        //记录当前帧的标志位        curSampleFlags = mediaExtractor.getSampleFlags();        //进入下一帧        mediaExtractor.advance();        return buffercount;    }
复制代码
首先,先通过 selectTrack 来指定是解析视频还是音频,接着使用 readBuffer 这个方法,该方法使用 mediaExtractor.readSampleData(buffer, 0); 去拿到当前视频的 buffer,并通过 mediaExtractor.advance() 拿到下一帧的数据。
1.2 解码流程

上一章说到 MediaCodec 的解码是基于以下两张图的:
MediaCodec 工作图

MediaCodec状态图
如果你对这两张图的流程不熟悉,请阅读 Android 音视频编解码(一) – MediaCodec 初探
为了方便后续的音频解码,这里我们定义一个基类,用来解析视频和音频,由于是同步解析视频,我们应该在线程中去解析,所以继承 Runnable:
  1.     /**     * 解码基类,用于解码音视频     */    abstract class BaseDecode implements Runnable {        final static int VIDEO = 1;        final static int AUDIO = 2;        //等待时间        final static int TIME_US = 1000;        MediaFormat mediaFormat;        MediaCodec mediaCodec;        MyExtractor extractor;        private boolean isDone;        public BaseDecode() {            try {                //获取 MediaExtractor                extractor = new MyExtractor(Constants.VIDEO_PATH);                //判断是音频还是视频                int type = decodeType();                //拿到音频或视频的 MediaFormat                mediaFormat = (type == VIDEO ? extractor.getVideoFormat() : extractor.getAudioFormat());                String mime = mediaFormat.getString(MediaFormat.KEY_MIME);                //选择要解析的轨道                extractor.selectTrack(type == VIDEO ? extractor.getVideoTrackId() : extractor.getAudioTrackId());                //创建 MediaCodec                mediaCodec = MediaCodec.createDecoderByType(mime);                //由子类去配置                configure();                //开始工作,进入编解码状态                mediaCodec.start();            } catch (IOException e) {                e.printStackTrace();            }        } }
复制代码
可以看到,根据视频或者视频拿到不同的 MediaFormat ,并根据 mime 类型,创建 MediaCodec ;

这样,第一步就创建好了,接着,从上面的状态知道,现在处于Uninitialized 状态;
接着需要调用 configure方法,进入 Configured 状态,这一步由子类完成,比如视频的:
  1. @Override void configure() {     mediaCodec.configure(mediaFormat, new Surface(mTextureView.getSurfaceTexture()), null, 0); }
复制代码
可以看到,通过mediaCodec.configure() 配置了当前的 MediaFormat,和要播放视频的 Surface,这里用的是 TextureView。
接着,调用 MediaCodec 的 start() ,进入 Executing 状态,可以编解码了。
1.3 视频解码

解码流程根据这张图

1.3.1 输入

上面说到 BaseDecode 继承 Runnable ,所以,解码的流程在 run 方法中。
  1.         @Override        public void run() {            try {                                MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();                //编码                while (!isDone) {                    /**                     * 延迟 TIME_US 等待拿到空的 input buffer下标,单位为 us                     * -1 表示一直等待,知道拿到数据,0 表示立即返回                     */                    int inputBufferId = mediaCodec.dequeueInputBuffer(TIME_US);                    if (inputBufferId > 0) {                        //拿到 可用的,空的 input buffer                        ByteBuffer inputBuffer = mediaCodec.getInputBuffer(inputBufferId);                        if (inputBuffer != null) {                            /**                             * 通过 mediaExtractor.readSampleData(buffer, 0) 拿到视频的当前帧的buffer                             * 通过 mediaExtractor.advance() 拿到下一帧                             */                            int size = extractor.readBuffer(inputBuffer);                            //解析数据                            if (size >= 0) {                                mediaCodec.queueInputBuffer(                                        inputBufferId,                                        0,                                        size,                                        extractor.getSampleTime(),                                        extractor.getSampleFlags()                                );                            } else {                                //结束,传递 end-of-stream 标志                                mediaCodec.queueInputBuffer(                                        inputBufferId,                                        0,                                        0,                                        0,                                        MediaCodec.BUFFER_FLAG_END_OF_STREAM                                );                                isDone = true;                            }                        }                    }                    //解码输出交给子类                    boolean isFinish =  handleOutputData(info);                    if (isFinish){                        break;                    }                }                done();            } catch (Exception e) {                e.printStackTrace();            }        }        protected void done(){            try {                isDone = true;                //释放 mediacodec                mediaCodec.stop();                mediaCodec.release();                //释放 MediaExtractor                extractor.release();            } catch (Exception e) {                e.printStackTrace();            }        }        abstract boolean handleOutputData(MediaCodec.BufferInfo info);
复制代码
在一个 while 循环中,不断的解码,上面的代码做到以下流程:

  • 从 MediaCodec 拿到一个空闲的 buffer
  • 从视频中,拿到视频的当前帧的数据,并填充到 MediaCodec 的buffer中
  • 使用 mediaCodec.queueInputBuffer() 把buffer 的数据交给 MediaCodec 去解码
1.3.2 输出

上面的输出过程,由 handleOutputData() 去实现,去到 VideoDecodeSync ,代码实现如下:
  1. @Overrideboolean handleOutputData(MediaCodec.BufferInfo info) {    //等到拿到输出的buffer下标    int outputId = mediaCodec.dequeueOutputBuffer(info, TIME_US);    if (outputId >= 0){        //释放buffer,并渲染到 Surface 中        mediaCodec.releaseOutputBuffer(outputId, true);    }    // 在所有解码后的帧都被渲染后,就可以停止播放了    if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {        Log.e(TAG, "zsr OutputBuffer BUFFER_FLAG_END_OF_STREAM");        return true;    }    return false;}
复制代码
上面的代码也做到两件事:

  • 拿到output buffer
  • 释放 buffer,并渲染视频到 Surface 中,由 releaseOutputBuffer() 第二个参数控制。
这样,视频解码部分我们就写好了。效果如下:

但是,你会发现,视频好像是倍速播放一样。
1.3.3 矫正显示时间戳

为什么会出现上面的情况呢?
正常来说,我们的视频播放的帧率大概为 30,即 30fps,大概每 33.33ms 播放一帧;但解码的出一帧的时间大概就是几 ms 的事情,如果一解码就直接给 Surface 去显示的话,视频看起来就想倍速播放的一样。
那你可能说,直接延时 30ms 播放就可以了啊,那不是标准吗? 是的,30fps 是标准,但不代表每个视频都是30,这里就需要学习 音视频的基础知识 ,DTS 和 PTS。


  • DTS (Decoding Time Stamp) : 即解码时间戳,这个时间戳的意义在于告诉播放器该在什么时候解码这一帧的数据
  • PTS (Presentation Time Stamp) :显示时间戳,这个时间戳告诉播放器,什么时候播放这一帧
    需要注意的是,虽然 DTS 、PTS 是用于指导播放端的行为,但他们是在编码的时候,由编码器生成的。
    在没有B帧的情况下,DTS和 PTS 的输出顺序是一样的,一旦存在 B 帧,则顺序不一样。
这里,我们只需要关心 PTS ,即显示时间戳。通过 MediaCodec.BufferInfo 的 presentationTimeUs 可以拿到当前的 pts 时间戳,单位是微妙,它是相对于0开始播放的时间,所以,我们可以使用系统时间差来模仿两帧的时间差,这样当解码过来的 pts 比这个时间差快,则延时以下再输出到 Surface ,如果不是,则直接显示到 Surface 中。
由于在线程中,所以,我们可以使用 Thread.sleep() 去实现,在渲染到 Surface 之前:
  1. // 用于对准视频的时间戳 private long startMs = -1;if (outputId >= 0){     if (mStartMs == -1) {            mStartMs = System.currentTimeMillis();      }        //矫正pts     sleepRender(info, startMs);     //释放buffer,并渲染到 Surface 中     mediaCodec.releaseOutputBuffer(outputId, true); }#sleepRender    /**     * 数据的时间戳对齐     **/    private void sleepRender(MediaCodec.BufferInfo info, long startMs) {        /**         * 注意这里是以 0 为出事目标的,info.presenttationTimes 的单位为微秒         * 这里用系统时间来模拟两帧的时间差         */        long ptsTimes = info.presentationTimeUs / 1000;        long systemTimes = System.currentTimeMillis() - startMs;        long timeDifference = ptsTimes - systemTimes;        // 如果当前帧比系统时间差快了,则延时以下        if (timeDifference > 0) {            try {                Thread.sleep(timeDifference);            } catch (InterruptedException e) {                e.printStackTrace();            }        }    }
复制代码
现在时间播放就正常了。

1.4 解码音频

上面已经理解了视频的解码,那么音频的解码就比较简单了,新建一个 AudioDecodeSync 类,继承BaseDecode ,在 configure 方法中去配置 MediaCodec ,由于不需要 Surface ,传递 null 即可。
  1. @Override void configure() {     mediaCodec.configure(mediaFormat, null, null, 0); }
复制代码
虽然我们不需要使用 Surface ,但是需要播放视频,那么则需要使用 AudioTrack,如果对它不需要,可以参考 Android 音视频开发(一) – 使用AudioRecord 录制PCM(录音);AudioTrack播放音频.
所以,在 AudioDecodeSync 的构造方法中,需要配置以下 AudioTrack :
  1.     class AudioDecodeSync extends BaseDecode {        private int mPcmEncode;        //一帧的最小buffer大小        private final int minBufferSize;        private AudioTrack audioTrack;        public AudioDecodeSync() {            //拿到采样率            if (mediaFormat.containsKey(MediaFormat.KEY_PCM_ENCODING)) {                mPcmEncode = mediaFormat.getInteger(MediaFormat.KEY_PCM_ENCODING);            } else {                //默认采样率为 16bit                mPcmEncode = AudioFormat.ENCODING_PCM_16BIT;            }            //音频采样率            int sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);            //获取视频通道数            int channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);            //拿到声道            int channelConfig = channelCount == 1 ? AudioFormat.CHANNEL_IN_MONO : AudioFormat.CHANNEL_IN_STEREO;            minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, mPcmEncode);            /**             * 设置音频信息属性             * 1.设置支持多媒体属性,比如audio,video             * 2.设置音频格式,比如 music             */            AudioAttributes attributes = new AudioAttributes.Builder()                    .setUsage(AudioAttributes.USAGE_MEDIA)                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)                    .build();            /**             * 设置音频数据             * 1. 设置采样率             * 2. 设置采样位数             * 3. 设置声道             */            AudioFormat format = new AudioFormat.Builder()                    .setSampleRate(sampleRate)                    .setEncoding(AudioFormat.ENCODING_PCM_16BIT)                    .setChannelMask(channelConfig)                    .build();            //配置 audioTrack            audioTrack = new AudioTrack(                    attributes,                    format,                    minBufferSize,                    AudioTrack.MODE_STREAM, //采用流模式                    AudioManager.AUDIO_SESSION_ID_GENERATE            );            //监听播放            audioTrack.play();        }        }
复制代码
拿到 AudioTrack 之后,就可以使用 play() 方法,去监听是否有数据写入,开始播放音频了。
在 handleOutputData:
  1.         @Override        boolean handleOutputData(MediaCodec.BufferInfo info) {            //拿到output buffer            int outputIndex = mediaCodec.dequeueOutputBuffer(info, TIME_US);            ByteBuffer outputBuffer;            if (outputIndex >= 0) {                outputBuffer = mediaCodec.getOutputBuffer(outputIndex);                //写数据到 AudioTrack 只,实现音频播放                audioTrack.write(outputBuffer, info.size, AudioTrack.WRITE_BLOCKING);                mediaCodec.releaseOutputBuffer(outputIndex, false);            }            // 在所有解码后的帧都被渲染后,就可以停止播放了            if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {                Log.e(TAG, "zsr OutputBuffer BUFFER_FLAG_END_OF_STREAM");                return true;            }            return false;        }
复制代码
你会发现音频正常播放了,且没有什么快进的意思,因为音频的时间戳是比较连续的,因此不用矫正。
1.5 音视频同步

那么,如何让音视频同步呢?其实也不算难,就是开辟两个线程,让它同时播放即可:
  1. if (mExecutorService.isShutdown()) {            mExecutorService = Executors.newFixedThreadPool(2);        }        mVideoSync = new VideoDecodeSync();        mAudioDecodeSync = new AudioDecodeSync();        mExecutorService.execute(mVideoSync);        mExecutorService.execute(mAudioDecodeSync);  }
复制代码
二、异步解码

在 5.0 之后,google 建议使用异步解码的方式去使用 MediaCodec,使用也非常简单,只需要使用 setCallback 方法即可。
比如解析上面的视频,步骤可以如下:

  • 使用 MediaExtractor 解析视频,拿到 MediaFormat
  • 使用 MediaCodec.setCallback() 方法
  • 调用 mediaCodec.configure() 和 mediaCodec.start() 开始解码
所以,代码如下:
  1.     class AsyncDecode {        MediaFormat mediaFormat;        MediaCodec mediaCodec;        MyExtractor extractor;        public AsyncDecode() {            try {                //解析视频,拿到 mediaformat                extractor = new MyExtractor(Constants.VIDEO_PATH);                mediaFormat = (extractor.getVideoFormat());                String mime = mediaFormat.getString(MediaFormat.KEY_MIME);                extractor.selectTrack(extractor.getVideoTrackId());                mediaCodec = MediaCodec.createDecoderByType(mime);            } catch (IOException e) {                e.printStackTrace();            }        }        private void start() {            //异步解码            mediaCodec.setCallback(new MediaCodec.Callback() {                @Override                public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {                                                ByteBuffer inputBuffer = codec.getInputBuffer(index);                    int size = extractor.readBuffer(inputBuffer);                    if (size >= 0) {                        codec.queueInputBuffer(                                index,                                0,                                size,                                extractor.getSampleTime(),                                extractor.getSampleFlags()                        );                        handler.sendEmptyMessage(1);                    } else {                        //结束                        codec.queueInputBuffer(                                index,                                0,                                0,                                0,                                MediaCodec.BUFFER_FLAG_END_OF_STREAM                        );                    }                }                @Override                public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {                                     mediaCodec.releaseOutputBuffer(index, true);                }                @Override                public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {                    codec.reset();                }                @Override                public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {                }            });            //需要在 setCallback 之后,配置 configure            mediaCodec.configure(mediaFormat, new Surface(mTextureView.getSurfaceTexture()), null, 0);            //开始解码            mediaCodec.start();        }    }
复制代码
使用异步解码,与同步解码的流程基本一致;只不过,在同步的代码中,我们通过
  1. int inputBufferId = mediaCodec.dequeueInputBuffer(TIME_US);
复制代码
去等待拿到空闲的 input buffer 下标,而在异步中,则是通过回调
  1. void onInputBufferAvailable(@NonNull MediaCodec codec, int index)
复制代码
拿到 input buffer 的下标。不出意外的话,你的视频已经开始播放了,但是也遇到上面的问题,就是倍速播放了,原因我们也解释过,就是没矫正 PTS 的问题。
由于我们的代码在主线程中去进行的,直接 Thread.sleep() 肯定会卡顿的,但是我们可以使用 HandlerThread 或其他线程的方式去解析,这里就不贴了,具体看源码吧。
参考:
https://www.jianshu.com/p/ba8db84f8fe8
https://mp.weixin.qq.com/s?__biz=MzUxODQ3MTk5Mg==&mid=2247483868&idx=1&sn=99dec978a4640136c870965ba853c204&chksm=f989298bcefea09dddae613023139e0c6bd2f4eaaa9dc3ec16400645fc4a0764414134ad73ea&scene=38#wechat_redirect
https://developer.android.google.cn/reference/android/media/MediaCodec?hl=en

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
免责声明
1. 本论坛所提供的信息均来自网络,本网站只提供平台服务,所有账号发表的言论与本网站无关。
2. 其他单位或个人在使用、转载或引用本文时,必须事先获得该帖子作者和本人的同意。
3. 本帖部分内容转载自其他媒体,但并不代表本人赞同其观点和对其真实性负责。
4. 如有侵权,请立即联系,本网站将及时删除相关内容。
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表