Android 音视频开发【音频篇】【四】音频编码

2,374 阅读6分钟

Pcm格式的音频文件,在一般的播放器上是无法播放的,需要将其编码,本章就来学习如何对音频进行编码

一、MediaCodec

Android中,有一个名叫MediaCodec的组件,它能够将原始数据编码成指定格式,也能够将编码格式解码为原始格式

它还能使用Surface作为输入或者输出进行编码和解码,不过在音频中,主要还是直接使用其编码或解码的数据进行相关操作

1.1 简单介绍

先放一张Google官网上的图

MediaCodec流程图.png

在图中我们可以看到:

  • 分为了两个端,一个是客户端,表示调用者,一个是服务端,表示编解码器
  • 调用者先从编解码器中请求,或者说是获取一个空的buffer,得到这个空的buffer后,将数据装入这个空的buffer送入编解码器
  • 编解码器得到调用者传送过来的数据,会将进行对应的处理
  • 调用者再从编解码器中获取输出的buffer,此时的buffer就是处理完成的buffer,调用者使用完成后,清空其中的数据,并返还给编解码器

调用者在传入数据到MediaCodec后,无需关心MediaCodec是如何处理的,只需要关心数据是否正常即可

下面来介绍下MediaCodec的使用方式

1.2 使用方式

MediaCodec有两种使用方式,分别是:

  • 同步方式
  • 异步方式

具体的流程如下(来自Google官网):

同步方式

MediaCodec codec = MediaCodec.createByCodecName(name);
codec.configure(format, …);
MediaFormat outputFormat = codec.getOutputFormat(); // option B
codec.start();
for (;;) {
 int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
 if (inputBufferId >= 0) {
   ByteBuffer inputBuffer = codec.getInputBuffer(…);
   // fill inputBuffer with valid data
   …
   codec.queueInputBuffer(inputBufferId, …);
 }
 int outputBufferId = codec.dequeueOutputBuffer(…);
 if (outputBufferId >= 0) {
   ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
   MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
   // bufferFormat is identical to outputFormat
   // outputBuffer is ready to be processed or rendered.
   …
   codec.releaseOutputBuffer(outputBufferId, …);
 } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
   // Subsequent data will conform to new format.
   // Can ignore if using getOutputFormat(outputBufferId)
   outputFormat = codec.getOutputFormat(); // option B
 }
}
codec.stop();
codec.release();

可以看到,同步的使用方式有以下几个步骤

  1. 创建实例

  2. 配置MediaCodec

  3. 启动MediaCodec

  4. 开启死循环

    • 获取输入的ByteBuffer,填充数据后,调用queueInputBuffer方法插入编解码器
    • 获取输出的ByetBuffer,获取编解码后的数据,并做对应的业务处理,调用releaseOutputBuffer方法释放该ByteBuffer数据
  5. 停止编解码,释放资源

死循环中,我们可以使用一个标志位来控制是否退出循环,即退出编解码

异步方式

MediaCodec codec = MediaCodec.createByCodecName(name);
MediaFormat mOutputFormat; // member variable
codec.setCallback(new MediaCodec.Callback() {
 @Override
 void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {
    ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
   // fill inputBuffer with valid data
   …
   codec.queueInputBuffer(inputBufferId, …);
 }
​
 @Override
 void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, …) {
   ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
   MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
   // bufferFormat is equivalent to mOutputFormat
   // outputBuffer is ready to be processed or rendered.
   …
   codec.releaseOutputBuffer(outputBufferId, …);
 }
​
 @Override
 void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
   // Subsequent data will conform to new format.
   // Can ignore if using getOutputFormat(outputBufferId)
   mOutputFormat = format; // option B
 }
​
 @Override
 void onError(…) {
   …
 }
});
codec.configure(format, …);
mOutputFormat = codec.getOutputFormat(); // option B
codec.start();
// wait for processing to complete
codec.stop();
codec.release();

可以看到,异步方式同步方式存在的不同:

  • 异步方式在配置MediaCodec时,会设置一个回调
  • 回调中,主要是在onInputBufferAvailableonOutputBufferAvailable
  • onInputBufferAvailable中执行插入数据的逻辑
  • onOutputBufferAvailable中执行处理编解码后数据的逻辑
  • 最后同样是释放资源

在了解了MediaCodec后,下面就开始进入对Pcm的编码

二、音频编码

前面章节中,介绍了如何使用AudioRecord进行Pcm数据的采集,本章同样会使用到,不过,不同的是,在采集到Pcm数据后,需要送入编码器,进行编码操作,最终获取可以在一般设备上播放的aac文件

回顾下之前AudioRecord的采集Pcm步骤

  1. 开启子线程
  1. 构建实例
  1. 开始录制
  1. 循环从AudioRecord读取数据,写入文件
  1. 停止录制,释放资源

要进行编码的话,写入文件的数据应该是编码后的数据,所以步骤如下:

  1. 开启子线程
  2. 构建实例
  3. 开始录制、编码
  4. 循环从AudioRecord读取数据,送入编码器
  5. 从编码器中取出编码后的数据,写入文件
  6. 停止录制、编码,释放资源

因为之前AudioRecord是通过死循环的方式不断读取数据,那么此次编码就采用同步方式

下面,就上述步骤,一一讲解

2.1 开启子线程

同样,编码操作也需要在子线程中执行

private static class EncodeThread extends Thread {
    public EncodeThread(){}
}

2.2 配置必要参数

类似于AudioRecord,进行一些参数配置

private static class EncodeThread extends Thread {
    private static final long TIMEOUT_MS = 2000L;
    private AudioRecord audioRecord;
    private MediaCodec mediaCodec;
    /**
     * 文件输出
     */
    private FileOutputStream fos;
    private final String path;
    /**
     * 声源(一般是来自麦克风)
     */
    private final int audioSource;
    /**
     * 采样率
     */
    private final int sampleRateInHz;
    /**
     * 声道设置
     */
    private final int channelConfig;
    /**
     * 编码格式
     */
    private final int audioFormat;
    /**
     * 比特率
     */
    private final int bitRate;
    /**
     * 最大输入size
     */
    private final int maxInputSize;
    /**
     * 编码类型
     */
    private final String mine;
    /**
     * 声道数
     */
    private int channelCount;
    /**
     * 音频缓存buffer
     */
    private int bufferSizeInByte;
    /**
     * 是否停止编码
     */
    private boolean isStopEncode = false;
    /**
     * 构造方法(传入必要的参数)
     */
    public EncodeThread(String path,
                        int audioSource,
                        int sampleRateInHz,
                        int channelConfig,
                        int audioFormat,
                        int bitRate,
                        int maxInputSize,
                        String mime
    ) {
        this.path = path;
        this.audioSource = audioSource;
        this.sampleRateInHz = sampleRateInHz;
        this.channelConfig = channelConfig;
        this.audioFormat = audioFormat;
        this.bitRate = bitRate;
        this.maxInputSize = maxInputSize;
        this.mine = mime;
    }
}

可以注意到,多了bitRatemaxInputSizemime参数,其中mime表示编码的类型,用于构建编码器

2.3 初始化

重写Threadrun方法 # run()

@Override
public void run() {
    super.run();
    initIo();
    initAudioRecord();
    initMediaCodec();
    encode();
}

里面调用了三个方法,分别是:

  • initIo()

    初始化文件输出流

  • initAudioRecord()

    初始化录音组件

  • initMediaCodec()

    初始化编码器

  • encode()

    真正开始编码

# initIo()

private void initIo() {
    if (TextUtils.isEmpty(path)) {
        return;
    }
    File file = new File(path);
    if (file.exists()) {
        file.delete();
    }
    try {
        fos = new FileOutputStream(path);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
        fos = null;
    }
}

# initAudioRecord()

private void initAudioRecord() {
    bufferSizeInByte = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat);
    audioRecord = new AudioRecord(audioSource, sampleRateInHz, channelConfig, audioFormat, bufferSizeInByte);
}

# initMediaCodec()

private void initMediaCodec() {
    channelCount = 1;
    if (channelConfig == AudioFormat.CHANNEL_IN_MONO) {
        channelCount = 1;
    } else if (channelConfig == AudioFormat.CHANNEL_IN_STEREO) {
        channelCount = 2;
    }
    MediaFormat format = MediaFormat.createAudioFormat(
            mine, sampleRateInHz, channelCount);
    format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
    format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
    format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize);
    try {
        mediaCodec = MediaCodec.createEncoderByType(mine);
        mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    } catch (IOException e) {
        e.printStackTrace();
        mediaCodec = null;
    }
}

在构建MediaCodec实例时,有一个MediaFormat的参数需要我们创建,其中bitRate是必须设置的,不然编码器会报错,还需要注意的一个地方是,AudioRecord初始化时,声道设置是个常量,而在创建MediaFormat时,需要转换为具体的声道数

2.4 编码

接下来开始真正的编码

# encode()

private void encode() {
    if (audioRecord == null || fos == null || mediaCodec == null) {
        return;
    }
    MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
    audioRecord.startRecording();
    mediaCodec.start();
    for (; ; ) {
        if (isStopEncode) {
            release();
            break;
        }
        int inputBufferId = mediaCodec.dequeueInputBuffer(TIMEOUT_MS);
        if (inputBufferId >= 0) {
            ByteBuffer inputBuffer = mediaCodec.getInputBuffer(inputBufferId);
            int readSize = -1;
            if (inputBuffer != null) {
                readSize = audioRecord.read(inputBuffer, bufferSizeInByte);
            }
            if (readSize <= 0) {
                mediaCodec.queueInputBuffer(
                        inputBufferId,
                        0,
                        0,
                        0,
                        MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                isStopEncode = true;
            } else {
                mediaCodec.queueInputBuffer(
                        inputBufferId,
                        0,
                        readSize,
                        System.nanoTime() / 1000,
                        0);
            }
        }
        int outputBufferId = mediaCodec.dequeueOutputBuffer(info, TIMEOUT_MS);
        if (outputBufferId >= 0) {
            ByteBuffer outputBuffer = mediaCodec.getOutputBuffer(outputBufferId);
            int size = info.size;
            if (outputBuffer != null && size > 0) {
                byte[] data = new byte[size + 7];
                addADTSHeader(data, size + 7);
                outputBuffer.get(data, 7, size);
                outputBuffer.clear();
                try {
                    fos.write(data);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                mediaCodec.releaseOutputBuffer(outputBufferId, false);
            }
        }
    }
}

可以注意到,编码里面的逻辑比AudioRecord采集Pcm时复杂多了,不过代码结构还是比较清晰的

AudioRecord采集到Pcm后,之前我们是直接将其写入文件,而这里是需要送入编码器,接着在得到输出buffer后才写入数据

注意,写入文件前,需要对数据做一个addADTSHeader的处理,也就是对编码完成的数据增加一个7字节ADTS头信息

为什么要这样做呢?

如果添加该头信息,则在播放器中是无法播放的,而编码器编码出来的数据是不带这个头信息的,所以需要我们自己添加进去

# addADTSHeader()

/**
 * 添加AAC帧文件头
 *
 * @param packet    packet
 * @param packetLen packetLen
 */
private void addADTSHeader(byte[] packet, int packetLen) {
    int profile = 2; // AAC
    int freqIdx = 4; // 44.1kHz
    packet[0] = (byte) 0xFF;
    packet[1] = (byte) 0xF9;
    packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (channelCount >> 2));
    packet[3] = (byte) (((channelCount & 3) << 6) + (packetLen >> 11));
    packet[4] = (byte) ((packetLen & 0x7FF) >> 3);
    packet[5] = (byte) (((packetLen & 7) << 5) + 0x1F);
    packet[6] = (byte) 0xFC;
}

可以注意到,添加头信息,有一些参数是固定的,而有一些参数是可变的,我们主要关注可变的信息,比如:编码格式采样率声道数帧长

三、GitHub

PcmEncode.java

PcmActivity.java