阅读 71

Android 音视频 Java 框架

音视频编码

所谓音视频编码就是指通过特定的压缩技术,将某个音视频格式的文件转换成另一种音视频格式文件的格式。

音视频编码的意义

音视频编码的最终目的是对音视频进行压缩,未经编码(压缩)的音视频数据量大,存储困难,不利于网络传输。如果视频不经过编码压缩,在直播场景下做不到实时传输的需求。

视频编码格式

  1. H.264 (AVC) 也被成为高级视频编码(Advanced Video Codding),是一种视频压缩视频标准,简称AVC,一种被广泛使用的高精度视频的录制,压缩和发布格式。
  2. H.265 (HEVC) 也被称为高效视频编码(High Efficiency Video Coding),是一种视频压缩视频标准,简称 HEVC。可支持 4K 分辨率,最高分辨率可达到 8192x4320 (8K分辨率),目前的发展趋势。

音频编码格式

  1. AAC 也被成为高级音频编码 (Advanced Audio Codding) ,又被称为 MPEG-4 AAC,是一种有损压缩的音频编码集。其设计目的是为了替代原有的 MP3 编码标准,在相同码率下质量优于 MP3 编码。
  2. MP3 全称 MEPG-1 or MEPG-2 Audio Layer III,曾经非常流行的一种数字音频编码和有损压缩格式。
  3. WMA 全称 Windows Media Audio,是微软公司开发的一种数字音频编码格式,本身支持有损和无损压缩。

目前 H.264 和 AAC 是最流行的编码压缩技术,无论媒体文件还是实时媒体流,H.264 一般与 AAC 一起使用。

H.264 (AVC) 与 x264

H.264 是一个视频编码标准,由国际上两个著名的视频专家组(VCEG 和 MPEG) 合作提出的,两个专家组既称它为 H.264,也称为 AVC。

x264 是一个编码器,它实现的参考标准就是 H.264,x264 是符合 H.264 的开源项目,是H.264 的简化版,不支持某些高级特性。常见的 H.264 的编解码器有 JM 和 x264。

音频视频编解码器的比较

封装格式

AVI , 3GP ,MP4 都是一种视频封装格式, 是存储特定视频编码格式的容器,一般为文件扩展名。封装格式只是媒体数据的容器,不会影响视频画质,是把特定轨道按照特定格式存储到文件中。封装格式可以存储多种编码格式 ,如 QuickTime 几乎可以存储所有视频编码格式。

主流封装格式

  • AVI

      文件扩展名: .avi
      缺点:体积大,只能存储一个视频轨道和音频轨道。不支持添加字幕。
      优点:画质好
    复制代码
  • WMV

      文件扩展名: .wmv
      缺点:用户量少,普及低。
      优点:高清编码格式,体积小,适合在线播放和传输。
    复制代码
  • MPEG

      文件扩展名: .mpg , .mpeg , .3gp , .mp4 等
      缺点:有损的文件格式
      优点:支持字幕,可以封装多种编码的视频和音频。
    复制代码

封装格式对音视频体积影响较小,体积主要由编码格式决定。

帧率

帧率是视频显示帧数的量度,简称 fps 或者 赫兹 (Hz)。每秒显示帧数或者帧率表示 GPU 处理时每秒能够绘制的次数。帧率越高画面更逼真和流畅,一般 30 fps 可以满足大部分场景,如果帧率超过屏幕的刷新频率,只会浪费 GPU。

刷新率

刷新率是指屏幕每秒内刷新的次数,单位 赫兹 (Hz) ,频率越高越流畅。一般如果达到 80hz ,可以消除图像闪烁和抖动。

码率

码率俗称比特率,比特率是单位时间内连续媒体的比特数量。码率越高,文件体积越大。通过码率我们可以计算出文件的大小:

文件大小 (b) = 码率(b/s)* 时长(s)
复制代码

一般情况下码率越大画质越好,但也和具体编码算法有关。

DTS 和 PTS

DTS : Decode Time Stamp ,一般用于标识该比特流在什么时候送入解码器解码。(因为之间有参考关系)

PTS : Presentation Time Stamp,一般用于解码后的视频帧什么时候被显示。

视频帧的类型

I 帧:表示为关键帧,I 帧可以独立解码,可以理解为一帧完整的画面,解码时不参考其他帧。

P 帧:表示这一帧和之前一个 I 帧或 P 帧的差别帧,只包含前一帧的差别数据,解码时需要用之前的 I 帧或 P 帧叠加和本帧的差别生成图像。

B 帧:表示双向差别帧,B 帧记录的是本帧和与前后帧差别,解码时需要使用本帧叠加前后帧。B 帧最小,但是解码时性能较低。

MediaMuxer

MediaMuxer的作用是生成音频或视频文件;还可以把音频与视频混合成一个音视频文件。

MediaMuxer(String path, int format):path:输出文件的名称 format:输出文件的格式;当前只支持MP4格式;

addTrack(MediaFormat format):添加通道;我们更多的是使用MediaCodec.getOutpurForma()或Extractor.getTrackFormat(int index)来获取MediaFormat;也可以自己创建;

start():开始合成文件

writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo):把ByteBuffer中的数据写入到在构造器设置的文件中;

stop():停止合成文件

release():释放资源

使用

MediaMuxer muxer = new MediaMuxer("temp.mp4", OutputFormat.MUXER_OUTPUT_MPEG_4);
 // More often, the MediaFormat will be retrieved from MediaCodec.getOutputFormat()
 // or MediaExtractor.getTrackFormat().
 MediaFormat audioFormat = new MediaFormat(...);
 MediaFormat videoFormat = new MediaFormat(...);
 int audioTrackIndex = muxer.addTrack(audioFormat);
 int videoTrackIndex = muxer.addTrack(videoFormat);
 ByteBuffer inputBuffer = ByteBuffer.allocate(bufferSize);
 boolean finished = false;
 BufferInfo bufferInfo = new BufferInfo();

 muxer.start();
 while(!finished) {
   // getInputBuffer() will fill the inputBuffer with one frame of encoded
   // sample from either MediaCodec or MediaExtractor, set isAudioSample to
   // true when the sample is audio data, set up all the fields of bufferInfo,
   // and return true if there are no more samples.
   finished = getInputBuffer(inputBuffer, isAudioSample, bufferInfo);
   if (!finished) {
     int currentTrackIndex = isAudioSample ? audioTrackIndex : videoTrackIndex;
     muxer.writeSampleData(currentTrackIndex, inputBuffer, bufferInfo);
   }
 };
 muxer.stop();
 muxer.release();
复制代码

从MP4文件中提取视频并生成新的视频文件

private boolean process() throws IOException {
    mMediaExtractor = new MediaExtractor();
    mMediaExtractor.setDataSource(SDCARD_PATH + "/ss.mp4");

    int mVideoTrackIndex = -1;
    int framerate = 0;
    for (int i = 0; i < mMediaExtractor.getTrackCount(); i++) {
        MediaFormat format = mMediaExtractor.getTrackFormat(i);
        String mime = format.getString(MediaFormat.KEY_MIME);
        if (!mime.startsWith("video/")) {
            continue;
        }
        framerate = format.getInteger(MediaFormat.KEY_FRAME_RATE);
        mMediaExtractor.selectTrack(i);
        mMediaMuxer = new MediaMuxer(SDCARD_PATH + "/ouput.mp4", MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        mVideoTrackIndex = mMediaMuxer.addTrack(format);
        mMediaMuxer.start();
    }

    if (mMediaMuxer == null) {
        return false;
    }

    MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
    info.presentationTimeUs = 0;
    ByteBuffer buffer = ByteBuffer.allocate(500 * 1024);
    int sampleSize = 0;
    while ((sampleSize = mMediaExtractor.readSampleData(buffer, 0)) > 0) {

        info.offset = 0;
        info.size = sampleSize;
        info.flags = MediaCodec.BUFFER_FLAG_SYNC_FRAME;
        info.presentationTimeUs += 1000 * 1000 / framerate;
        mMediaMuxer.writeSampleData(mVideoTrackIndex, buffer, info);
        mMediaExtractor.advance();
    }

    mMediaExtractor.release();

    mMediaMuxer.stop();
    mMediaMuxer.release();

    return true;
}
复制代码

MediaCodec

MediaCodec 第一次可用是在 Android 4.1版本(API16 ),一开始是用来直接访问设备的媒体编解码器。它提供了一种极其原始的接口。MediaCodec类同时存在 Java和C++层中,但是只有前者是公共访问方法。

在Android 4.3 (API18)中,MediaCodec被扩展为包含一种通过 Surface 提供输入的方法(通过 createInputSurface 方法),这允许输入来自于相机的预览或者是经过OpenGL ES呈现。

而且Android4.3还引入了 MediaMuxer,它允许将AVC编解码器(原始H.264基本流)的输出转换为.MP4格式,可以和音频流一起转码也可以单独转换。

Android5.0(API21)引入了“异步模式”,它允许应用程序提供一个回调方法,在缓冲区可用时执行。

MediaCodec 可以说是一个非常 "重量级" 的 API ,一个 MediaCodec 类就可以完成编解码。

使用 MediaCodec 我们不仅可以借助 GPU 实现硬编硬解,而且手机厂商也会内置一些高效的编解码器。

MediaCodec 自 Android 4.1 (API 16) 引入的编解码接口,它是 Android 多媒体架构的一部分,通常和 MediaExtracto, MediaCrypto, Image, Surface, AudioTrack, MediaMuxer 一起使用。

而提起 MediaCodec 不得不提的是下面两个框架:

  • StageFright
  • OpenMAX

StageFright

MediaCodec 是提供给上层应用的 Java 接口,实际底层调用的是 StageFright 多媒体框架, StageFright 是 Android 平台预设的多媒体框架,自 Andorid 2.3 开始才被引入进来。

最早 MediaCodec 调用的多媒体框架是 OpenCore,OpenCore 的优点是跨平台的,但是由于过于庞大和复杂,自 Android 2.3 开始 StageFright 正式加入,OpenCore 有可能会被 StageFright 取代。

OpenMAX 基本介绍

StageFright 底层编解码能力是由 OpenMAX 框架提供,StageFright 遵循 OpenMAX 标准,OpenMAX 全称是 Open Media Acceleration ( 开发多媒体加速器 )。

OpenMAX 为多媒体软硬开发提供了一套标准接口,OpenMAX 是为音视频,图像编解码而设计,许多嵌入式设备都使用了 OpenMAX 标准 ,比如 Android 平台。

OpenMAX 标准定义了 DL,IL, AL 层:

DL ( Devlopment Layer  开发层 )
DL 层定义了音视频,图像处理接口,一般 DL 层由设备芯片厂商提供实现,并提供编解码器的功能。 在 Android 系统中,Google 提供了一些内置的软编解码器:
1. OMX.google.h264.encoder,
2. OMX.google.h264.decoder,
3. OMX.google.acc.encoder,
4. OMX.google.acc.decoder
如果手机厂商需要提供硬编解码器就需要实现 DL 层。

IL ( Integration Layer 整合层 )

AL ( Application Layer 应用层 )
为应用和多媒体中间件提供了标准化接口,StageFright 和 OpenCore 应该就是遵循该标准。由于遵循的标准一致, StageFright 取代 OpenCore 时并不会影响上层应用。
复制代码

通过上面介绍,我们知道 MediaCodec 是通过调用 StageFright 调用编解码功能,StageFright 又是通过 OpenMAX 与硬件层进行通信。如下图:

设备支持的编解码器列表

当设备芯片厂商开发完成编解码器后, 会将编解码器信息注册到 system/etc/media_codecs.xml 和 system/etc/media_profiles.xml 文件中,我们可以通过分析这两个文件获取当前设备所有的编解码器列表,解析解码器最大支持的视频宽高等信息。

可通过 adb 获取文件的具体内容

  1. 连接手机
  2. 打开终端执行如下命令
 adb shell
 cat /system/etc/media_codecs.xml
复制代码

media_codecs.xml 部分内容:

MediaExtractor 的基本使用

对音视频媒体文件解码时,我们首先需要分离出媒体文件的音视频轨道,MediaExtractor 就是干这个的,它可以告诉你媒体中轨道(Track)数量,并根据索引读取指定轨道数据。

API

  1. setDataSource(String path)

    为分离器指定数据源,支持网络地址和本地地址

  2. getTrackCount()

    获取轨道数据数量

  3. getTrackFormat(int index)

    获取指定索引位置的轨道格式信息

  4. selectTrack(int index)

    根据轨道索引选中指定轨道,选中后将分离器将读取选中轨道的数据,读取数据之前须选中一个轨道,而且同时只能选中一个轨道。

  5. seekTo(long timeUs,int mode)

    根据帧时间(timeUs)及搜寻模式(mode),搜寻最匹配的关键帧。

    注意:分离视频轨道时,seekTo 不能精确到视频时间,seekTo 只能根据 mode 找到最匹配的关键帧。

    timeUs :搜寻的帧时间

    mode: 搜寻模式

    SEEK_TO_CLOSEST_SYNC 最接近 timeUS 的关键帧

    SEEK_TO_NEXT_SYNC timeUS 的下一个关键帧

    SEEK_TO_PREVIOUS_SYNC timeUS 的上一个关键帧

  6. advance()

    将分离器游标移动到下一帧

  7. readSampleData(ByteBuffer buffer, int offset)

    读取当前位置样本数据

  8. getSampleTrackIndex()

    获取当前选中的轨道索引

  9. getSampleTime()

    当前分离器样本时间

  10. getSampleFlags()

    获取当前样本类型,为 SAMPLE_FLAG_SYNC 时表示为关键帧

  11. release()

    读取结束后释放资源

下面是分离视频轨道的关键步骤,音频轨道步骤一致,只需要选择对应的 mime type 索引即可。

创建一个媒体分离器

MediaExtractor extractor = new MediaExtractor();
复制代码

为媒体分离器装载媒体文件路径

// 指定文件路径
String videoPath = "xxx.mp4";
extractor.setDataSource(videoPath);

// 指定 Uri
Uri videoUri =  xx
extractor.setDataSource(context,fileUri,null);
复制代码

获取并选中指定类型的轨道

// 媒体文件中的轨道数量 (一般有视频,音频,字幕等)
int trackCount = extractor.getTrackCount();

// mime type 指示需要分离的轨道类型
String extractMimeType = "video/";

// 记录轨道索引id,MediaExtractor 读取数据之前需要指定分离的轨道索引
int trackID = -1;
// 视频轨道格式信息
MediaFormat trackFormat;

for (int i = 0;i < trackCount; i++) {
    trackFormat = extractor.getTrackFormat(i);     
   if (trackFormat.getString(MediaFormat.KEY_MIME).startsWith(extractMimeType)) {
         trackID = i;
         break;
   }
 } 
 
 // 媒体文件中存在视频轨道
 if (trackID != -1){
      extractor.selectTrack(trackID);
 }
复制代码

分离指定轨道的数据

// 获取最大缓冲区大小,
int maxInputSize = trackFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE);
// 开辟一个字节缓冲区,用于存放分离的媒体数据
ByteBuffer byteBuffer = ByteBuffer.allocate(maxInputSize);

// 记录当前帧数据大小
int sampleDataSize = 0;
while((sampleDataSize = extractor.readSampleData(byteBuffer,0)) > 0) {

   MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
   bufferInfo.offset = 0;
   bufferInfo.presentationTimeUs = extractor.getSampleTime();
   bufferInfo.size = sampleDataSize;
   bufferInfo.flags = extractor.getSampleFlags();
                 
    Log.i("presentationTimeUs : %s",extractor.getSampleTime());
}
// 释放分离器,释放后 extractor 将不可用
extractor.release();
extractor = null;
复制代码

MediaFormat 媒体格式

MediaFormat 封装了媒体数据(音频,视频,字幕)格式的信息,所有信息都以键值对形式表示。MediaFormat 中定义的 key 对于不同媒体数据并不是全部通用的,某些 key 只适用于特定媒体数据。

通用 Keys

KEY_MIME 格式类型

KEY_MAX_INPUT_SIZE 输出缓冲区的最大字节数

KEY_BIT_RATE 比特率

Video Keys

KEY_WIDTH 视频宽度

KEY_HEIGHT 视频高度

KEY_DURATION 内容持续时间(以微妙为单位)

KEY_CORLOR_FORMAT 视频内容颜色空间

KEY_FRAME_RATE 视频帧率

KEY_I_FRAME_INTERVAL 关键之间的时间间隔

KEY_ROTATION 视频旋转顺时针角度

KEY_BITRATE 码率/比特率(画质和文件体积)

KEY_BITRATE_MODE 比特率模式

BITRATE_MODE_CBR : 编码器尽可能将输出码率控制为设置值

BITRATE_MODE_CQ : 编码器完全不控制码率,尽可能保证图图像质量

BITRATE_MODE_VBR :编码器根据图像内容及复杂度动态调整输出码率

Audio Keys

KEY_CHANNEL_COUNT 通道数

KEY_SAMPLE_RATE 采样率

KEY_DURATION 内容持续时间(以微妙为单位)

MediaCodec 的基本使用

MediaCodec 是被用来对媒体文件进行编解码。

MediaCodec的一个实例会处理一种类型的数据,(比如,MP3音频或H.264视频),编码或是解码。它对原始数据操作,所有任何的文件头,比如ID3(一般是位于一个mp3文件的开头或末尾的若干字节内,附加了关于该mp3的歌手,标题,专辑名称,年代,风格等信息,该信息就被称为ID3信息)这些信息会被擦除。它不与任何高级的系统组件通信,也不会通过扬声器来播放音频,或是通过网络来获取视频流数据,它只是一个会从缓冲区取数据,并返回数据的中间层。

一些编解码器对于它们的缓冲区是比较特殊的,它们可能需要一些特殊的内存对齐或是有特定的最小最大限制,为了适应广泛的可能性,buffer缓冲区分配是由编解码器自己实现的,而不是应用程序的层面。你并不需要一个带有数据的缓冲区给 MediaCodec,而是直接向它申请一个缓冲区,然后把你的数据拷贝进去。

这看起来和“零拷贝”原则是相悖的,但大部分情况发生拷贝的几率是比较小的,因为编解码器并不需要复制或调整这些数据来满足要求,而且大多数我们可以直接使用缓冲区,比如直接从磁盘或网络读取数据到缓冲区中,不需要复制。

MediaCodec的输入必须在“access units”中完成,在编码H.264视频时意味着一帧,在解码时意味着是一个NAL单元,然而,它看起来感觉更像是流,你不能提交一个单块,并期望不久后就出现,实际上,编解码器可能会在输出前入队好几个buffers。

API

  1. createDecoderByType(String mimeType)

    根据 mime type 创建一个解码器(mimeType 可通过 MediaExtractor 的 MediaFormat 中获取)

  2. createEncoderByType(String mimeType)

    根据 mime type 创建一个编码器

  3. configure(MediaFormat format,Surface surface,MediaCrypto crypto,int flags)

    配置编解码器

    format: 当为解码器时表示为输入的媒体格式,编码器时表示为输出的媒体格式

    surface:Surface 可与 SurfaceTexture 配合使用可将解码后的数据渲染到指定纹理中

    crypto: 如果视频被加密的话,需要配置该参数解密 (DRM 相关)

    flags: 解码器为:0 编码器配置为:1/CONFIGURE_FLAG_ENCODE

  4. getInputBuffers()

获取需要编解码的输入流队列。为了提高性能 MediaCodec 内部维护了输入和输出缓冲区队列,只有当输入队列空闲时方可写入数据。

5.dequeueInputBuffer(long timeoutUs)

从输入缓冲区请求空闲的输入队列索引。

timeoutUs:指定 MediaCodec 当前没有空闲输入队列时最大等待时间。

当请求到空闲队列后返回 ByteBuffer,将 ByteBuffer 填充数据后可调用 queueInputBuffer 加入编解码队列。

  1. queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags)

将指定 index 输入缓冲区加入编解码器队列,等待编解码操作。

index : 输入的缓冲区索引 一般通过 dequeueInputBuffer 方法获取。

  1. getOutputBuffers()

获取编解码数据输出队列

  1. dequeueOutputBuffer(BufferInfo info, long timeoutUs)

请求完成编解码后的输出队列索引

info: 接收当前编解码后的信息

timeoutUs:指定 MediaCodec 当前没有数据输出事最大超时时间,

  1. releaseOutputBuffer(int index,boolean render)

当从 MediaCodec 请求的输出队列索引处理完成后归还至缓冲区。

index : 通过 dequeueOutputBuffer 请求的队列索引

render: 指定是否渲染到 Surface 如果为 false Surface 将无法接受到该帧的数据输出。

  1. start()/stop()/release()

启动、停止、释放。

示例

// step 1:创建一个媒体分离器
MediaExtractor extractor = new MediaExtractor();

// step 2:为媒体分离器装载媒体文件路径
// 指定文件路径
 Uri videoPathUri = Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.img_video);
try {
         extractor.setDataSource(this, videoPathUri, null);
} catch (IOException e) {
     e.printStackTrace();
     }
     
// step 3:获取并选中指定类型的轨道
 // 媒体文件中的轨道数量 (一般有视频,音频,字幕等)
int trackCount = extractor.getTrackCount();
// mime type 指示需要分离的轨道类型
String extractMimeType = "video/";
MediaFormat trackFormat = null;
// 记录轨道索引id,MediaExtractor 读取数据之前需要指定分离的轨道索引
int trackID = -1;
 for (int i = 0; i < trackCount; i++) {
    trackFormat = extractor.getTrackFormat(i);
    if (trackFormat.getString(MediaFormat.KEY_MIME).startsWith(extractMimeType)) {
      trackID = i;
          break;
       }
     }
     
 // 媒体文件中存在视频轨道
 // step 4:选中指定类型的轨道
 if (trackID != -1)
   extractor.selectTrack(trackID);

 // step 5:根据 MediaFormat 创建解码器
 MediaCodec mediaCodec = null;
   try {
   mediaCodec = MediaCodec.createDecoderByType(trackFormat.getString(MediaFormat.KEY_MIME));
   mediaCodec.configure(trackFormat,null,null,0);
   mediaCodec.start();
  } catch (IOException e) {
        e.printStackTrace();
  }


  while (true) {
     // step 6: 向解码器喂入数据
      boolean ret = feedInputBuffer(extractor,mediaCodec);
     // step 7: 从解码器吐出数据
       boolean decRet = drainOutputBuffer(mediaCodec);
      if (!ret && !decRet)break;;
    }

// step 8: 释放资源
// 释放分离器,释放后 extractor 将不可用
 extractor.release();
// 释放解码器
mediaCodec.release();
复制代码
/**
 * 喂入数据到解码器
 *
 * @return true 喂入成功
 * @since v3.0.1
 */
public boolean feedInputBuffer(MediaExtractor source, MediaCodec codec) {

    if (source == null || codec == null) return false;

    int inIndex = codec.dequeueInputBuffer(0);
    if (inIndex < 0)  return false;

    ByteBuffer codecInputBuffer = codec.getInputBuffers()[inIndex];
    codecInputBuffer.position(0);
    int sampleDataSize = source.readSampleData(codecInputBuffer,0);

    if (sampleDataSize <=0 ) {

        // 通知解码器结束
        if (inIndex >= 0)
            codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
        return false;
    }

    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
    bufferInfo.offset = 0;
    bufferInfo.presentationTimeUs = source.getSampleTime();
    bufferInfo.size = sampleDataSize;
    bufferInfo.flags = source.getSampleFlags();

    switch (inIndex)
    {
        case INFO_TRY_AGAIN_LATER: return true;
        default:
        {

            codec.queueInputBuffer(inIndex,
                    bufferInfo.offset,
                    bufferInfo.size,
                    bufferInfo.presentationTimeUs,
                    bufferInfo.flags
            );

            source.advance();

            return true;
        }
    }

}

/**
 * 吐出解码后的数据
 *
 * @return true 有可用数据吐出
 * @since v3.0.1
 */
public boolean drainOutputBuffer(MediaCodec mediaCodec) {

    if (mediaCodec == null) return false;

    final
    MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
    int outIndex =  mediaCodec.dequeueOutputBuffer(info, 0);

    if ((info.flags & BUFFER_FLAG_END_OF_STREAM) != 0) {
        mediaCodec.releaseOutputBuffer(outIndex, false);
        return false;
    }

    switch (outIndex)
    {
        case INFO_OUTPUT_BUFFERS_CHANGED: return true;
        case INFO_TRY_AGAIN_LATER: return true;
        case INFO_OUTPUT_FORMAT_CHANGED:return true;
        default:
        {
            if (outIndex >= 0 && info.size > 0)
            {
                MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                bufferInfo.presentationTimeUs = info.presentationTimeUs;
                bufferInfo.size = info.size;
                bufferInfo.flags = info.flags;
                bufferInfo.offset = info.offset;

                // 释放
                mediaCodec.releaseOutputBuffer(outIndex, false);

                Log.i(TAG,String.format("pts:%s",info.presentationTimeUs));

                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        mInfoTextView.setText(String.format("正在解码中..\npts:%s",info.presentationTimeUs));
                    }
                });

            }

            return true;
        }
    }
}
复制代码

解码后的视频尚未渲染在屏幕上,在渲染到屏幕之前我们需要对 OpenGLES 有所了解,并需要知道 Surface 及 SurfaceView 的基本使用。

OpenGL ES

OpenGL ES 是 OpenGL 三维图像 API 的子集,是为手机,PAD和游戏机等嵌入式设备而设计。OpenGL ES 目前支持 iOS、Android、BlackBerry、bada、Linux 和 Windows。

由于 OpenGL API 相当复杂,并且在嵌入式设备上很多功能并没有什么卵用,Khronos 组织牵头对 OpenGL API 进行了删减,最终诞生了 OpenGL ES。

OpenGL ES 在移动设备上做了很多优化,例如,降低电源消耗,提高着色器性能,在着色器语言中引入精度限定符(highp、mediump、lowp)。

Context 是 OpenGL 中的一个重要概念,理解 Context 我们首先需要知道状态机,OpenGL 本身是一个巨大且复杂的状态机,当调用一个 GL 函数时,其实,就是在改变 OpenGL 当前的状态信息,比如:颜色、纹理坐标、光照、混合、深度测试等。而这些状态信息都保存在 Context 上下中,因此渲染的时候,必须创建当前环境的 Context 。在 Android 中 Context 使用 EGLContext 对象表示。

OpenGL ES 着色器

OpenGL ES 中相当重要的一部分是 GL Shader Language(GLSL),GLSL 是 OpenGL ES 开放给我们的可编程部分,通常,我们编写的代码运行在 CPU 中,但 GLSL 在 GPU 中运行。 GLSL 由顶点(vertex)着色器和片段(fragment)着色器构成, 可以在着色器中自定义我们自己的渲染逻辑,比如,滤镜、素描、马赛克特效等。

GLSL 的语法与 C 语言比较类似,GLSL 包括:

  • 变量
  • 变量类型
  • main 函数
  • 结构体
  • 数组
  • 限定符

变量类型

void :用于函数无返回值或无参数列表声明

标量 :float、int 、bool 浮点、整型、布尔型

浮点向量 :float、vec2 、vec3、vec4 包含1、2、3、4个元素的浮点型向量

整数向量 :int、ivec2 、ivec3、ivec4 包含1、2、3、4个元素的整型向量

布尔向量 :bool、bvec2 、bvec3、bvec4 包含1、2、3、4个元素的布尔型向量

矩阵 :mat2、mat3 、mat4 为 2x2、3x3、4x4 的浮点型矩阵

纹理句柄 :sampler2D、samplerCube 表示 2D、3D纹理句柄

获取向量分量时即可以通过 "." 符号也可以通数组下标的方法,由于向量在 GLSL 中常常用来表示颜色、纹理坐标等, GLSL 提供了通过 {x, y, z, w} , {r, g, b, a} 或 {s, t, r, q} 操作来获取向量分量,这种方式在编写 GLSL 代码时很容易可以断定该向量的意义。

GLSL 限定符

限定符是对变量的解释说明,并限定变量在 GLSL 中的使用场景,在 GLSL 中支持如下限定符:

attribute :

只能用在顶点着色器中,一般用于表示顶点数据。由程序通过 
glGetAttribLocation 获取 attribute 地址,并通过 glEnableVertexAttriArray / glVertexAttribPointer 为 attribute 属性赋值。
复制代码

varying :

可用于顶点和片段着色器,一般用于在着色器之间做数据传递。通常,
varying 在顶点着色器中进行计算,片段着色器使用 varying 计算后的值。
复制代码

uniform :

可用于顶点和片段着色器, 由程序通过 glGetUniformLocation 获取地址 ,并通过 glUniforml 系列函数复制。
复制代码

顶点着色器

在一个 OpenGL ES 程序中,顶点着色器和片元着色器是标准配置,顶点着色器用于定义绘制的形状,片元着色器为这个形状上色。

例如,我们如果想要绘制一个三角形,我们首先确定三角形的三个顶点坐标,并将顶点信息告知顶点着色器,顶点着色器根据顶点坐标绘制三角形,然后交由片元着色器为三角形粉刷颜色。通常,顶点着色器为每个顶点调用一次顶点着色器。

下面是一个非常简单的顶点着色器:

"attribute vec3 aPosition;" +
"void main(void) {" +
"    gl_Position = vec4(aPosition,1.0);" +
"}";
复制代码

片元着色器

"片元" 可以简单理解为像素,片元着色器也就意味着我们可以操作图像的像素,比如,颜色、坐标、深度等。所以,片元着色器就是我们实现各种特效的地方。

片元着色器总是在顶点着色器之后执行,片元着色器会为每个 "片元" 执行一次片元着色器,这意味着顶点着色器和片元着色器的执行次数并不是相同的。你可能会产生疑问?? 如果不相同顶点着色器的顶点坐标如何传入片元着色器呢???

如果要搞清楚这个问题,我们就需要知道 OpenGL 的渲染管线,如下图:

渲染管线是指图形数据经过一系列处理过程,最终输出到屏幕上,这个过程就像一个输送管道,或者一个处理流水线,它有着固定的处理顺序。

从上图管线,我们可以看到在顶点着色器和片元着色器之间有图元装配、几何着色器、光栅化阶段。

图元装配 (Primitive Assembly):

将顶点着色器输出的所有顶点作为输入,根据指定类型(GL_POINTS、GL_LINES、GL_TRIANGLES)装配图元形状。
复制代码

光栅化 (Resterization Stage):

光栅化阶段会将图元形状映射为最终屏幕上显示的像素,然后生成供片元着色器使用的 "片元",然后将每个片元输入片元着色器。
复制代码

下面是一个简单的片元着色器代码:

"precision mediump float;" +
"void main(void) {" +
"    gl_FragColor = vec4(1.0,0.5,0.2,1.0);" +
"}"
复制代码

OpenGL ES Program

Program 是 OpenGL 另外一个重要的概念,一个完整的 GL 程序顶点着色器、片元着色器、Program 对象是必不可少的部分,缺一不可。

Program 通过链接顶点着色器和片元着色器,并将 Program 激活后,后续我们执行的绘制命令,会在 Program 链接的顶点着色器和片元着色器中执行。

创建一个 完整的 GL 程序的过程大致如下:

// step1:创建一个 Program 程序
int program = GLES20.glCreateProgram();
checkGlError("glCreateProgram");
 
// step2:编译顶点着色器
int vShader = loadShader(GLES20.GL_VERTEX_SHADER,VERTEX_SHADER);

// step3:编译片元着色器
int fShader = loadShader(GLES20.GL_FRAGMENT_SHADER,FRAGMENT_SHADER_2D);

// step4:将 Program 与顶点着色器和片元着色器链接
if (!linkProgram(program,vShader,fShader)) {
       
    GLES20.glDeleteProgram(program);
    GLES20.glDeleteShader(vShader);
    GLES20.glDeleteShader(fShader);
}

/**
 * 加载并编译着色器
*
* @param shaderType 着色器类型 GLES20.GL_VERTEX_SHADER /  GLES20.GL_FRAGMENT_SHADER
* @param source 着色器源码
* @return 着色器句柄
*/
private static int loadShader(int shaderType, String source) {
        int shader = GLES20.glCreateShader(shaderType);
        checkGlError("glCreateShader type=" + shaderType);
        GLES20.glShaderSource(shader, source);
        GLES20.glCompileShader(shader);
        int[] compiled = new int[1];
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
        if (compiled[0] == 0) {
            AVLog.e("Could not compile shader " + shaderType + ":");
            AVLog.e(GLES20.glGetShaderInfoLog(shader));
            GLES20.glDeleteShader(shader);
            shader = 0;
        }
        return shader;
}

/**
* 链接程序
*
* @param program 程序句柄
* @param vShader 顶点着色器句柄
* @param fShader 片元着色器句柄
* @return 是否链接成功
*/
private static boolean linkProgram(int program,int vShader,int fShader){

    GLES20.glAttachShader(program,vShader);
    checkGlError("glAttachShader vShader");
    GLES20.glAttachShader(program,fShader);
    checkGlError("glAttachShader fShader");

    GLES20.glLinkProgram(program);
    checkGlError("glLinkProgram");

    int[] linkStatus = new int[1];
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
    if (linkStatus[0] != GLES20.GL_TRUE) {
        AVLog.e("Could not link program: ");
        AVLog.e(GLES20.glGetProgramInfoLog(program));
        return false;
    }
    return true;
}
复制代码

OpenGL ES 纹理

纹理、贴图、材质的概念都比较相似,大致关系是:材质(Material)> 贴图(Map)> 纹理(Texture)( > 表示为包含关系), 纹理是最小输入单位,贴图更多是用来做纹理映射,贴图包含纹理及纹理的 UV 坐标,材质不仅包含纹理和贴图,更主要的功能是提供了光照、透明度、折射、质感等属性信息。

你可以把纹理想象成墙面上的壁纸,它可以为物体添加细节,有更强的视觉感受。如下图所示:

在 GLSL 中纹理类型使用 sampler2D (2D世界)表示,在片元着色器中我们已经看到纹理变量的声明方式为:

uniform sampler2D sTexture;
复制代码

我们知道 uniform 属性值由应用程序赋值,

/** 生成一个纹理id,texutes 用以接收纹理句柄id */
int[] texutes = new int[1];
GLES20.glGenTextures(1,textures,0);

/** Bitmap 与 纹理绑定 */
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test_img);
// GLUtils 可以直接将 bitmap 与 纹理 id 绑定
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
bitmap.recycle();
复制代码

如果要把改纹理绘制到屏幕上,还需指定纹理的映射关系,通常我们需要指定顶点坐标,每个顶点坐标对应一个纹理坐标(Texture Coordiate),用来标明纹理图像的哪部分被采集片段颜色(采样)。

2D 纹理坐标(x,y)范围在 0 - 1 之间,它是一个归一化坐标,不依赖实际分辨率。 纹理坐标起始点为(0,0),(0,0) 在纹理图片的左下角,与 Android 屏幕坐标系 y 轴相反,终始于(1,1),即纹理图片的右上角。使用纹理坐标获取纹理颜色的过程叫做纹理采样(Sampling)。

OpenGL 绘制纹理

顶点着色器:

private static final String VERTEX_SHADER =
            "attribute vec4 aPosition;" +
                    "attribute vec4 aTextureCoord;" +
                    "varying vec2 vTextureCoord;" +
                    "void main() {" +
                    "    gl_Position =  aPosition;" +
                    "    vTextureCoord =  aTextureCoord.xy;" +
                    "}";
复制代码

在顶点着色其中我们声明了一个 aPosition 属性,aPosition 用以确定在窗口中的绘制位置。另外,我们也声明了一个 aTextureCoord 属性,该属性用来确定纹理坐标。 vTextureCoord 会传递给片元着色器,片元着色器通该属性的插值结果对纹理进行采样。

片元着色器:

private static final String FRAGMENT_SHADER_2D =
            "precision mediump float;" +
                    "varying vec2 vTextureCoord;" +
                    "uniform sampler2D sTexture;" +
                    "void main() {" +
                    "    gl_FragColor = texture2D(sTexture, vTextureCoord);" +
                    "}";
复制代码

在片元着色器中,我们通过 vTextureCoord 获取从顶点着色器传入的纹理坐标,通过定义 sampler2D 属性用来接收程序传入需要绘制的纹理,然后通过 texture2D 方法对纹理进行采样渲染。

紧接着,我们需要创建一个 Program ,并生产一个纹理 id,

// GPU2DTextureProgram 为 AVPlayer 封装的 2D 纹理绘制程序
m2DTextureProgram =  new GPU2DTextureProgram();

// 创建一个纹理
mImageTexure  = createTexture();

// 将图片与纹理进行绑定
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,mImageTexure);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test_img);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
bitmap.recycle();
复制代码

然后,我们在 GLSurafaceView 的 Render 方法中进行绘制

@Override
public void onDrawFrame(GL10 gl) {
   m2DTextureProgram.draw(mImageTexure);
}
复制代码

View 与 Surface 的渲染机制

如果提到 Surface 可能大部分开发者接触的比较少,在 Android 绘制系统中,Surface 是一个非常重要的概念,它向 Applicaiton 提供了 Canvas,向 SufaceFlinger 提供了可供显示的图像缓存。在 Surface 内部维护了图像 buffer 对象,这个最终就会交由 SufaceFlinger 合成显示。

在 Android 窗口中每个 Window 对象都会创建一个 Surface,这些窗口包括 Activity,Dialog,状态栏等,而我们使用的普通 View 与所属 Window 共享 Surface 实例,普通的 View 不会自己创建一个 Surface 对象,而是将内容绘制到所属 Window 中。之所以强调是普通 View,是由于 SurfaceView / GlSurafaceView 不会共享所属 Window 的 Surface,它会自己内部维护一个 Surface。

当 Window 被创建时,Window Manger 为每个 Window 创建一个 Surface,当窗口需要重绘时,Window 调用 lockCanvas 方法锁定并返回 Canvas,Window 通过遍历 View 层级,并调用 View 的 OnDraw(Canvas canvas) 方法将 Canvas 传递给 View ,View 通过 Canvas 绘制任何内容。 这一系列操作完成后 Surface 将被 unlock ,由 SurfaceFlinger 合成到屏幕上。

SurfaceView/GLSurfaceView 与 Surface 的关系

SurfaceView 是 View 的子类,与普通 View 不同之处在于,它内部有自己专用的 Surface,与宿主 Window 不共享 Surface。由于,SurfaceView 与宿主 Window 的分离,对 SurfaceView 的渲染操作我们可以放到单独的线程,这样的设计是因为一些游戏,视频应用的渲染极其复杂,为了不影响对主线程事件的响应,需要将这些渲染任务独立于主线程。

SurfaceView 的工作相对简单,最重要的任务仅仅是创建了 Surface,并在宿主窗口上打了一个洞,用以显示 Surface 的内容。 下面是部分源码:

public class SurfaceView extends View implements ViewRootImpl.WindowStoppedCallback {

    private static final String TAG = "SurfaceView";
    
    private static final boolean DEBUG = false;
    
    final ArrayList<SurfaceHolder.Callback> mCallbacks = new ArrayList<SurfaceHolder.Callback>();
    
    ------ 这里是重点 -----
    
    final Surface mSurface = new Surface(); // Current surface in use
}
复制代码

GLSurfaceView 与 Renderer

GLSurfaceView 确实是封装了 GL 的相关内容,严格来说是使用 EGL 搭建了 GL 环境。 让我们可以通过 Render 接口,就可以直接渲染我们要显示的内容。

GLSurfaceView 是对 SurfaceView 的扩展,不仅添加了 EGL 管理,而且为我们创建了一个 Renderer 线程,SurfaceView 的设计允许我们在主线程外执行渲染操作,而 GLSurfaceView 继承自 SurfaceView,并在内部创建了一个 GLThread,你的所有绘制任务,都将在 GLThread 线程中执行。

GLSurfaceView 有一个 setRenderer(Renderer renderer) 方法,它允许我们实现自己的渲染逻辑,Renderer 接口的定义如下:

public interface Renderer {
   /** Surface 创建成功,GL 环境已经准备完成 */
    void onSurfaceCreated(GL10 gl, EGLConfig config);

   /** Surface 的宽高发生变化,一般在横竖屏或者 GLSurfaceView 宽高方式变化时 */
    void onSurfaceChanged(GL10 gl, int width, int height);

    /** 在该方法内实现我们的 GL 渲染逻辑 */
    void onDrawFrame(GL10 gl);
}
复制代码

解码完成拿到每帧的 Texture 时,我们将在 Renderer 的 OnDrawFrame 方法中将 Texture 绘制到屏幕上。

SurfaceTexture 与 Surface

SurfaceView 是 Surface + View 的结合体,而 SurfaceTexture 是 Surface + GL Texture 的结合体,SurfaceTexture 可以将 Surface 中最近的图像数据更新到 GL Texture 中。通过 GL Texture 我们就可以拿到视频帧,然后直接渲染到 GLSurfaceView 中。

通过 setOnFrameAvailableListener(listener) 可以向 SurfaceTexture 注册监听事件,当 Surface 有新的图像可用时,调用 SurfaceTexture 的 updateTexImage() 方法将图像内容更新到 GL Texture 中,然后做绘制操作。

下面是 SurfaceTexture 的基本创建流程:

// step:1 创建绑定的纹理
int textures[] = new int[1];

GLES20.glGenTextures(1, textures, 0);

 int texId = textures[0];

GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId);

// step:2 通过 texId 将与 SurfaceTexture 绑定
SurfaceTexture surfaceTexture = new SurfaceTexture(texId);

// step:3 注册监听事件
surfaceTexture.setOnFrameAvailableListener(new surfaceTexture.OnFrameAvailableListener() {

            @Override
            public void onFrameAvailable(SurfaceTexture surfaceTexture) {
                // request render
            }
 });
复制代码

MediaCodec 解码视频并渲染

解码并渲染一个视频的标准流程如下:

  • 初始化 GLSurfaceView 设置,并制定 Renderer

  • 初始化 SurfaceTexture,并注册 onFrameAvaiableListener 监听

  • 初始化分离器,选择视频轨道

  • 初始化解码器,并配置 Surface

  • 实现 Renderer 接口,渲染视频纹理

step1: 初始化 GLSurfaceView 设置,并制定 Renderer

private void step1() {
     mSurfaceView = findViewById(surfaceView);

      // openGL ES 2.0
      mSurfaceView.setEGLContextClientVersion(2);

     mSurfaceView.setRenderer(mRenderer)

      // 设置渲染模式  GLSurfaceView.RENDERMODE_WHEN_DIRTY 只有使用  requestRender() 是才会触发渲染
     mSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
}
复制代码

step2: 初始化 SurfaceTexture,并注册 onFrameAvaiableListener 监听

private void step2() {
    mSurfaceTexture = new AVSurfaceTexture();
    mSurfaceTexture.getSurfaceTexture().setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() {

     // 当 MediaCodec 的 releaseOutputBuffer(idx,true) 调用后
     // OnFrame 被触发
        @Override
        public void onFrameAvailable(SurfaceTexture surfaceTexture) {
            // 通知 Renderer
            mSurfaceView.requestRender();
         }
    });
}
复制代码

step3: 初始化分离器,选择视频轨道

private void step3(){
 // step 1:创建一个媒体分离器
   mMediaExtractor = new MediaExtractor();

  // step 2:为媒体分离器装载媒体文件路径
 // 指定文件路径
    Uri videoPathUri = Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.demo_video);
    try {
            mMediaExtractor.setDataSource(this, videoPathUri, null);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // step 3:获取并选中指定类型的轨道
        // 媒体文件中的轨道数量 (一般有视频,音频,字幕等)
        int trackCount = mMediaExtractor.getTrackCount();

        // mime type 指示需要分离的轨道类型
        String extractMimeType = "video/";
        MediaFormat trackFormat = null;

        // 记录轨道索引id,MediaExtractor 读取数据之前需要指定分离的轨道索引
        int trackID = -1;
        for (int i = 0; i < trackCount; i++) {
            trackFormat = mMediaExtractor.getTrackFormat(i);
            if (trackFormat.getString(MediaFormat.KEY_MIME).startsWith(extractMimeType)) {
                trackID = i;
                break;
            }
        }

        // 媒体文件中存在视频轨道
        // step 4:选中指定类型的轨道
        if (trackID != -1)
            mMediaExtractor.selectTrack(trackID);
}
复制代码

step4: 初始化解码器,并配置 Surface

 private void step4() {
        MediaFormat trackFormat = mMediaExtractor.getTrackFormat(mMediaExtractor.getSampleTrackIndex());

        try {
            mMediaCodec = MediaCodec.createDecoderByType(trackFormat.getString(MediaFormat.KEY_MIME));
            /** configure 中指定 Surface */
            mMediaCodec.configure(trackFormat,mSurfaceTexture.getSurface(),null,0);

            mMediaCodec.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
}
复制代码

step5: 实现 Renderer 接口,渲染视频纹理

private GLSurfaceView.Renderer mRenderer = new GLSurfaceView.Renderer() {

         @Override
        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            mProgram = new GPUTextureProgram(GPUTextureProgram.ProgramType.TEXTURE_EXT);
        }

        @Override
        public void onSurfaceChanged(GL10 gl, int width, int height) {
            GLES20.glViewport(0,0 ,width,height);
        }

        @Override
        public void onDrawFrame(GL10 gl) {
            // 更新视频纹理
            mSurfaceTexture.updateTexImage();

            // 将纹理绘制到屏幕中
            mProgram.draw(mSurfaceTexture.getTextureID());
        }
};
复制代码

PCM

PCM (Pulse-code modulation 脉冲编码调制)是一种将模拟信号转为数字信号的方法。由于计算机只能识别数字信号,也就是一堆二进制序列,所以麦克风采集到的模拟信号会被模数转换器转换,生成数字信号。最常见的方式就是经过 PCM A/D 转换。

A/D 转换涉及到采样,量化和编码。

采样:由于存储空间有限,我们需要对模拟信号进行采样存储。采样就是从模拟信号进行抽样,抽样就涉及到采样频率,采样频率是每秒钟对声音样本的采样次数,采样率越高,声音质量越高,越能还原真实的声音。因此,我们一般称模拟信号是连续信号,数字信号为离散,不连续信号。

根据奈奎斯特理论,采样频率不低于音频信号最高频率的2倍,就可以无损的还原真实声音。

而由于人耳能听到的频率范围在 20Hz~20kHz,所以,为了保证声音不失真,采样频率我们一般设定为 40kHz 以上。常用的采样频率有 22.05kHz、16kHz、37.8kHz、44.1kHz、48kHz。目前在 Android 设备中,只有 44.1kHz 是所有设备都支持的采样频率。

量化:模拟信号经过采样成为离散信号,离散信号经过量化成为数字信号。量化是将经过采样得到的离散数据转换成二进制数的过程,量化深度表示每个采样点用多少比特表示,在计算机中音频的量化深度一般为4、8、16、32位(bit)等。

量化深度的大小影响到声音的质量,显然,位数越多,量化后的波形越接近原始波形,声音的质量越高,而需要的存储空间也越多;位数越少,声音的质量越低,需要的存储空间越少。CD音质采用的是16 bits,移动通信 8bits。

另外,WAV 文件其实就是 PCM 格式,因为播放 PCM 裸流时,我们需要知道 PCM 的采样率, 声道数, 位宽等信息,WAV 只是在文件头前添加了这部分描述信息,所以 WAV 文件可以直接播放。

PCM 是音频处理中频繁接触的格式,通常我们对音频的处理都是基于 PCM 流,如常见的音量调节, 变声, 变调等特性。

AudioTrack API

在 Android 中,如果你想要播放一个音频文件,我们一般优先选用 MediaPlayer,使用 MediaPlayer 时你不需要关心文件的具体格式,也不需要对文件进行解码,使用 MediaPlayer 提供的 API,我们就可以开发出一个简单的音频播放器。

AudioTrack 是播放音频的另外一种方式 「如果你感兴趣还可以了解下 SoundPool」, 并且只能用于播放 PCM 数据。

1. AudioTrack 初始化

/**
  * Class constructor.
  * @param streamType 流类型
  *   @link AudioManager#STREAM_VOICE_CALL, 语音通话
  *   @link AudioManager#STREAM_SYSTEM, 系统声音 如低电量
  *   @link AudioManager#STREAM_RING, 来电铃声
  *   @link AudioManager#STREAM_MUSIC, 音乐播放器
  *   @link AudioManager#STREAM_ALARM, 警告音
  *   @link AudioManager#STREAM_NOTIFICATION 通知
  *
  * @param sampleRateInHz 采样率
  *
  * @param channelConfig 声道类型
  *   @link AudioFormat#CHANNEL_OUT_MONO 单声道
  *   @link AudioFormat#CHANNEL_OUT_STEREO 双声道
  * @param audioFormat
  *  @link AudioFormat#ENCODING_PCM_16BIT,
  *  @link AudioFormat#ENCODING_PCM_8BIT,
  *  @link AudioFormat#ENCODING_PCM_FLOAT
  * @param bufferSizeInBytes 缓冲区大小
     * @param mode 模式
  *  @link #MODE_STATIC 静态模式 通过 write 将数据一次写入,适合较小文件
  *  @link #MODE_STREAM 流式模式 通过 write 分批写入,适合较大文件
  */    
    public AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat,int bufferSizeInBytes, int mode)
复制代码

2. 计算最小缓冲区大小

/**
 * @param sampleRateInHz 采样频率
 * @param channelConfig 声道数
 * @param audioFormat 位宽.
 */
static public int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat)
复制代码

初始化 AudioTrack 时的 bufferSizeInBytes 参数,可以通过 getMinBufferSize 计算算出合适的预估缓冲区大小,一般为 getMinBufferSize 的整数倍。

3. 写入数据

/**
   * @param audioData 保存要播放的数据的数组
   * @param offsetInBytes 在要写入数据的audioData中以字节表示的偏移量
   * @param sizeInBytes 在偏移量之后写入audioData的字节数。
 **/
public int write(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes)
复制代码

4. 开始播放

public void play()
复制代码

如果 AudioTrack 创建时的模式为 MODE_STATIC 时,调用 play 之前必须保证 write 方法已被调用。

5. 暂停播放

public void pause()
复制代码

暂停播放数据,尚未播放的数据不会被丢弃,再次调用 play 时将继续播放。

6. 停止播放

public void stop()
复制代码

停止播放数据,尚未播放的数据将会被丢弃。

7. 刷新缓冲区数据

public void flush()
复制代码

刷新当前排队等待播放的数据,已写入当未播放的数据将被丢弃,缓冲区将被清理。

MediaCodec 解码并播放音频轨道

如果我们要播放一个音频轨道,需要将音轨解码后才可以播放

private void doDecoder(){

        // step 1:创建一个媒体分离器
        MediaExtractor extractor = new MediaExtractor();
        // step 2:为媒体分离器装载媒体文件路径
        // 指定文件路径
        Uri videoPathUri = Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.demo_video);
        try {
            extractor.setDataSource(this, videoPathUri, null);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // step 3:获取并选中指定类型的轨道
        // 媒体文件中的轨道数量 (一般有视频,音频,字幕等)
        int trackCount = extractor.getTrackCount();
        // mime type 指示需要分离的轨道类型
        String extractMimeType = "audio/";
        MediaFormat trackFormat = null;
        // 记录轨道索引id,MediaExtractor 读取数据之前需要指定分离的轨道索引
        int trackID = -1;
        for (int i = 0; i < trackCount; i++) {
            trackFormat = extractor.getTrackFormat(i);
            if (trackFormat.getString(MediaFormat.KEY_MIME).startsWith(extractMimeType)) {
                trackID = i;
                break;
            }
        }
        // 媒体文件中存在视频轨道
        // step 4:选中指定类型的轨道
        if (trackID != -1)
            extractor.selectTrack(trackID);

        // step 5:根据 MediaFormat 创建解码器
        MediaCodec mediaCodec = null;
        try {
            mediaCodec = MediaCodec.createDecoderByType(trackFormat.getString(MediaFormat.KEY_MIME));
            mediaCodec.configure(trackFormat,null,null,0);
            mediaCodec.start();
        } catch (IOException e) {
            e.printStackTrace();
        }


        while (true) {
            // step 6: 向解码器喂入数据
            boolean ret = feedInputBuffer(extractor,mediaCodec);
            // step 7: 从解码器吐出数据
            boolean decRet = drainOutputBuffer(mediaCodec);
            if (!ret && !decRet)break;;
        }

        // step 8: 释放资源

        // 释放分离器,释放后 extractor 将不可用
        extractor.release();
        // 释放解码器
        mediaCodec.release();

        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                mDecodeButton.setEnabled(true);
                mInfoTextView.setText("解码完成");
            }
        });

    }
复制代码

解码音频时我们将 extractMimeType 设定为 "audio/" ,其它代码与解码视频时相同。

public boolean feedInputBuffer(MediaExtractor source, MediaCodec codec) {

        if (source == null || codec == null) return false;

        int inIndex = codec.dequeueInputBuffer(0);
        if (inIndex < 0)  return false;

        ByteBuffer codecInputBuffer = codec.getInputBuffers()[inIndex];
        codecInputBuffer.position(0);
        int sampleDataSize = source.readSampleData(codecInputBuffer,0);

        if (sampleDataSize <=0 ) {

            // 通知解码器结束
            if (inIndex >= 0)
                codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
            return false;
        }

        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
        bufferInfo.offset = 0;
        bufferInfo.presentationTimeUs = source.getSampleTime();
        bufferInfo.size = sampleDataSize;
        bufferInfo.flags = source.getSampleFlags();

        switch (inIndex)
        {
            case INFO_TRY_AGAIN_LATER: return true;
            default:
            {

                codec.queueInputBuffer(inIndex,
                        bufferInfo.offset,
                        bufferInfo.size,
                        bufferInfo.presentationTimeUs,
                        bufferInfo.flags
                );

                source.advance();

                return true;
            }
        }
    }
复制代码

接着我们监听到 INFO_OUTPUT_FORMAT_CHANGED 状态时,获取该音频轨道的格式信息, MediaFormat 提供了足够的信息可以让我们初始化 AudioTrack。

public boolean drainOutputBuffer(MediaCodec mediaCodec) {

        if (mediaCodec == null) return false;

        final
        MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
        int outIndex =  mediaCodec.dequeueOutputBuffer(info, 0);

        if ((info.flags & BUFFER_FLAG_END_OF_STREAM) != 0) {
            mediaCodec.releaseOutputBuffer(outIndex, false);
            return false;
        }

        switch (outIndex)
        {
            case INFO_OUTPUT_BUFFERS_CHANGED: return true;
            case INFO_TRY_AGAIN_LATER: return true;
            case INFO_OUTPUT_FORMAT_CHANGED: {
                MediaFormat outputFormat = mediaCodec.getOutputFormat();
                int sampleRate = 44100;
                if (outputFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
                    sampleRate = outputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
                int channelConfig = AudioFormat.CHANNEL_OUT_MONO;
                if (outputFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
                    channelConfig = outputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO;
                int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
                if (outputFormat.containsKey("bit-width"))
                    audioFormat = outputFormat.getInteger("bit-width") == 8 ? AudioFormat.ENCODING_PCM_8BIT : AudioFormat.ENCODING_PCM_16BIT;
                mBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) * 2;
                mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,sampleRate,channelConfig,audioFormat,mBufferSize,AudioTrack.MODE_STREAM);
                mAudioTrack.play();
                return true;
        }
            default:
            {
                if (outIndex >= 0 && info.size > 0) {
                    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                    bufferInfo.presentationTimeUs = info.presentationTimeUs;
                    bufferInfo.size = info.size;
                    bufferInfo.flags = info.flags;
                    bufferInfo.offset = info.offset;

                    ByteBuffer outputBuffer = mediaCodec.getOutputBuffers()[outIndex];
                    outputBuffer.position(bufferInfo.offset);
                    outputBuffer.limit(bufferInfo.offset + bufferInfo.size);

                    byte[] audioData = new byte[bufferInfo.size];
                    outputBuffer.get(audioData);

                       // 写入解码后的音频数据
                    mAudioTrack.write(audioData,bufferInfo.offset, Math.min(bufferInfo.size, mBufferSize));

                    // 释放
                    mediaCodec.releaseOutputBuffer(outIndex, false);

                    Log.i(TAG,String.format("pts:%s",info.presentationTimeUs));

                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            mInfoTextView.setText(String.format("正在解码中..\npts:%s",info.presentationTimeUs));
                        }
                    });
                }
                return true;
            }
        }
    }
复制代码

当我们通过 INFO_OUTPUT_FORMAT_CHANGED 获取到 MediaFormat 并初始化 AudioTrack 后,就可以通过 write 方法写入解码后的音频数据。

关注下面的标签,发现更多相似文章
评论