音视频基础能力之 Android 音频篇 (六):音频编解码到底哪家强?

涉及硬件的音视频能力,比如采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能。

本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现。本文为该系列文章的第 6 篇,将详细讲述在 Android 平台下如何实现音频的编解码。

一、前言

在之前的文章,我们详细的介绍了 Android 平台下的音频采集、渲染、路由的相关知识。如果你也想了解下相关的知识可以点击以下链接跳转:

音视频基础能力之 Android 音频篇(一): 音频采集

音视频基础能力之 Andoid 音频篇(二):音频录制

音视频基础能力之 Android 音频篇 (三):高性能音频采集

音视频基础能力之 Android 音频篇 (四):音频路由

音视频基础能力之 Android 音频篇 (五):史上最全的音频渲染总结

本文将详细的介绍下 Android 平台对音频编解码的处理,另外文章末尾提供了非常详细的 demo 源码,均验证过的。相信经过本文的学习,你将彻底的掌握这些知识。

在开始之前,先思考下以下三个问题,为了后面能够更轻松的理解这些知识。

音频数据为什么需要编码?

原始的数字信号数据太大了,以 Demo 提供的 pcm 文件属性为例:

该 pcm 文件的属性为采样率 44100Hz、双声道、每个采样点占 16 bit,其码率的为 1411 kb/s。

声道数 * 采样率 * 位深

如果在网络上实时的传输这个文件,势必会网络带宽带来压力,影响到其他更为重要的信令交互。所以,必须要压缩后才能传输。

音频数据编码的原理?

这里简单来了解下,其实原始音频流中并不是所有的数据都能够被人耳感知到的,人耳能够察觉到的声音信号的频率范围在 20Hz ~ 20kHz,那么在此范围之外的信号均可视为冗余信号。

另外,当一个强音和一个弱音信号同时存在时,弱音信号就被被强音信号所掩蔽,导致人耳无法听见,那么这种弱音信号也可以被认为是冗余信号。

这些冗余信号剔除后对人耳的听感上来说是没有任何变化的。

为什么音频很少用硬件编码?

  • 硬件编解码的实时性没有软件的好,另外 Android 平台的碎片化严重,从兼容性角度来说,软件编码更好。
  • 音频的数据量远小于视频,硬件加速并不明显。因为现代的手机 CPU 已足够强大,很多软件编解码库通过多线程(多核)的优化,在高端设备上,软件编解码比硬件更快。
  • 软件编解码灵活性更好,很多场景需要对音频数据进行实时处理(如降噪、混音、变声)等,软件编解码更易于集成。

二、硬件编解码

MediaCodec 是 Android 平台提供用于对音视频进行编解码的类,是 Android 多媒体框架的一部分,通常和 MediaExtractor、MediaMuxer 一起使用。

MediaCodec 提供了一组输入和输出缓冲区,简单的来说,你请求一个空的输入缓冲区,填入原始音视频数据,然后将其发送给编解码器进行处理。编解码器用完数据并将其转换为编码后的数据,输出道一块空的输出缓冲区上,最后通知你取一块已填充的输出缓冲区,使用完将其释放给编解码器。

编解码器内部的生命周期状态如上图所示,结合代码实现一起来学习下:

step1. 配置编码器

kotlin 复制代码
val mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
val mediaFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNEL_COUNT)
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_INPUT_SIZE)

mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)

以上的接口的含义分别是:

  • createAudioFormat 创建一个 AAC 音频格式的媒体类型的编码器
  • createAudioFormat 创建 aac 音频格式的 format,并制定它的采样率、声道数等。

需要注意的是

  • bit_rate 比特率的大小会影响音质的好坏,但是越大会使得编码出来的数据变大。
  • max_input_size 指定缓冲区的大小,越大音频编码输出的稳定性越好,但是延迟和它成负相关。

最后调用 configure 使得编码的状态变为上图中的 Configured 状态。

step2. 开启编码器

kotlin 复制代码
mediaCodec.start()

调用 start 接口会使得编码器的状态变为 Flushd 状态,这一状态表明编码器内部正是开始工作。

step3. 填充原始数据

kotlin 复制代码
while (!isEndOfStream) {
    val inputBufferIndex = mediaCodec.dequeueInputBuffer(TIMEOUT_US)
    if (inputBufferIndex >= 0) {
        val inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex)
        inputBuffer?.run {
            clear()
            // read file.
            val bytesRead = inputStream!!.read(buffer, 0, buffer.size)
            if (bytesRead == -1) {
                // file end.
                mediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                isEndOfStream = true
            } else {
                // 将PCM数据填充到输入缓冲区
                put(buffer, 0, bytesRead)
                // 将输入缓冲区提交给编码器
                mediaCodec.queueInputBuffer(inputBufferIndex, 0, bytesRead, 0, 0)
            }
        }
    }
  
    //....
  }

从前文的介绍得知,我们会从编码器中获取一块输入缓冲区的 buffer,然后将原始数据 pcm 拷贝到这块 buffer 中去,最后提交给编码器。

step4. 得到编码后数据

kotlin 复制代码
while (!isEndOfStream) {

		//... 填充输入缓冲区

    var outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
    while (outputBufferIndex >= 0) {
        val outputBuffer = mediaCodec.getOutputBuffer(outputBufferIndex)
        outputBuffer?.run {
            position(bufferInfo.offset)
            limit(bufferInfo.offset + bufferInfo.size)

            // add acc header.
            if (bufferInfo.size > 0 && (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
                val adtsHeader = ByteArray(7)
                addADTStoPacket(adtsHeader, bufferInfo.size + adtsHeader.size)
                aacOutputStream.write(adtsHeader)

                val data = ByteArray(bufferInfo.size)
                get(data)
                aacOutputStream.write(data)
            }

            // release output buffer.
            mediaCodec.releaseOutputBuffer(outputBufferIndex, false)
            outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
        }
    }
}

接着我们会从输出缓冲区获取索引,因为编码器编码完成后会将编码后的数据填充到输出缓冲区 buffer 上。如果能获取成功,我们就从 buffer 拿出数据写文件,需要注意的这里需要添加 ADTS 到 aac 包。

kotlin 复制代码
// 添加ADTS头到AAC包
private fun addADTStoPacket(packet: ByteArray, packetLen: Int) {
    val profile = 2  // AAC LC
    val freqIdx = 4  // 44.1KHz
    val chanCfg = CHANNEL_COUNT  // CPE

    // 填充ADTS头
    packet[0] = 0xFF.toByte()
    packet[1] = 0xF9.toByte()
    packet[2] = ((profile - 1 shl 6) + (freqIdx shl 2) + (chanCfg shr 2)).toByte()
    packet[3] = ((chanCfg and 3 shl 6) + (packetLen shr 11)).toByte()
    packet[4] = ((packetLen and 0x7FF) shr 3).toByte()
    packet[5] = (((packetLen and 7) shl 5) + 0x1F).toByte()
    packet[6] = 0xFC.toByte()
}

step5. 释放资源

kotlin 复制代码
// 关闭资源
inputStream?.close()
aacOutputStream.close()

// 停止并释放编码器
mediaCodec.stop()
mediaCodec.release()

整体代码流程结合上面两张图来看的话,还是比较简单的。解码过程是编码的逆过程,代码实现上比较相似,详见 Demo 源码,这里就不赘述了。

三、软件编解码

硬件编解码的实现相对于软件编解码来说,还是简单的多的。因为软件编解码可配置的地方比较多,也很灵活。软件编解码的实现有很多种,我们这里介绍下最为出名的 ffmpeg 的接入方式。

3.1 工程集成

首先需要使用 android ndk 提供的交叉编译链工具编译出产物,这里就不过多介绍了,产物可以从 demo 上拿来直接用。

step1. 拷贝产物放到下图的路径下。

step2. 配置 app module 下的 build.gradle 文件

这步的目的是为了配置 cmake,其中 AbiFilters 一定要配置,因为工程里面只有 arm64 架构的,不配置编译会报错。

kotlin 复制代码
android {
    
    //....

    defaultConfig {
        //...
        externalNativeBuild {
            cmake {
                cppFlags '-std=c++14'
            }
        }
        ndk {
            setAbiFilters(["arm64-v8a"])
        }
    }

		//...
    externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.22.1'
        }
    }
}

step3. 配置 CMakeLists.txt,指定头文件路径,将动态库加载进来。

kotlin 复制代码
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)

add_library(avcodec
        SHARED
        IMPORTED)
set_target_properties(avcodec
        PROPERTIES IMPORTED_LOCATION
        ${CMAKE_CURRENT_SOURCE_DIR}/lib/${CMAKE_ANDROID_ARCH_ABI}/libavcodec.so )
//... 其他的动态库类似

//link
target_link_libraries(${CMAKE_PROJECT_NAME}
        # List libraries link to the target library
        android avcodec swresample avformat avutil  log)

3.2 代码实现

为了方便大家理解,我整理了一张代码流程图。大头都是左边浅蓝色的步骤,其实都是在初始化和配置相关。整正干活的步骤是右边几个步骤,下面老司机带路,教你快速掌握 ffmpeg 实现音频的编解码。

3.2.1 初始化流程

step1. 配置编码器

指定编码器的参数、格式。需要注意的是,sample_fmt 采样格式 我们选择了 fltp 格式(平面浮点型),它的存储格式是这样的 LLLLLRRRRRR ,左声道和右声道分开存储,每个采样点占 32 个 bit,大端字节序。比较尬的是,我提供的 pcm 文件是 s16le 格式,它每个采样点是占 16 bit,小端字节序。

所以,我们后面还需要重采样来做下转化。那为什么不给编码器设置 s16le 格式呢,因为 ffmpeg AAC 编码器仅支持 fltp 格式。

c 复制代码
int configureCodec(AVCodecContext *&c, const AVCodec *codec) {
  c = avcodec_alloc_context3(codec);
  c->codec_id = AV_CODEC_ID_AAC;
  c->codec_type = AVMEDIA_TYPE_AUDIO;
  c->sample_fmt = AV_SAMPLE_FMT_FLTP;

  c->bit_rate = 96000;
  c->sample_rate = 44100;

  const AVChannelLayout src = AV_CHANNEL_LAYOUT_STEREO;
  av_channel_layout_copy(&c->ch_layout, &src);
  c->profile = FF_PROFILE_AAC_LOW;

  //打开编码器
  int ret = avcodec_open2(c, codec, nullptr);
  if (ret < 0) {
    LOGE("avcodec_open2 open failed, reason: %s", av_err2str(ret));
  }
  return ret;
}

step2. 分配输出格式

这里没什么好讲的,后面很多地方都用到这个对象的属性。

c 复制代码
  AVFormatContext* format_context = nullptr;
  const AVOutputFormat *ofmt = nullptr;
  avformat_alloc_output_context2(&format_context, nullptr, nullptr, out_file);
  if (!format_context) {
    LOGE("avformat_alloc_output_context2 failed");
    return -1;
  }

step3. 创建 AVStream

c 复制代码
  //创建音频流
  AVStream* stream = avformat_new_stream(format_context, nullptr);
  if (!stream) {
    LOGE("avformat_new_stream failed.");
    return -1;
  }
  
  //将编码器参数复制到流
  ret = avcodec_parameters_from_context(stream->codecpar, c);
  if (ret < 0) {
    LOGE("avcodec_parameters_from_context failed, ret:%d", ret);
    return -1;
  }

step4. 初始化重采样器

为了 s16le 和 fltp 格式转换用的。

kotlin 复制代码
struct SwrContext *swr_ctx = nullptr;
ret = swr_alloc_set_opts2(&swr_ctx, const_cast<const AVChannelLayout*>(&c->ch_layout), c->sample_fmt, c->sample_rate,
                    const_cast<const AVChannelLayout *>(&c->ch_layout),  AV_SAMPLE_FMT_S16, c->sample_rate, 0, nullptr);
if (ret < 0) {
  LOGE("swr_alloc_set_opts2 failed, ret: %d", ret);
  return -1;
}
if (!swr_ctx || swr_init(swr_ctx) < 0) {
  LOGE("swr_init failed.");
  return -1;
}

step5. 初始化 AVFrame 和 AVPacket

整个流程实现的必要对象,主要是数据的载体。

c 复制代码
/**  packet for holding encoded output. **/
AVPacket* pkt = av_packet_alloc();
/** frame containing input raw audio. **/
AVFrame* frame = av_frame_alloc();

if (!pkt || !frame) {
  LOGE("av_packet or av_frame alloc failed.");
}

frame->nb_samples = c->frame_size;
frame->format = c->sample_fmt;
av_channel_layout_copy(&frame->ch_layout, &c->ch_layout);

/**  分配缓冲区数据 **/
ret = av_frame_get_buffer(frame, 0);
if (ret < 0) {
  LOGE("alloc frame buffer failed.");
  return -1;
}

3.2.2 编码流程

step1. 读取数据

写文件头,从 pcm 文件读取数据出来,一般送往 AAC 编码器的采样点数 1024。

c 复制代码
//计算编码每帧 aac 所需要的 pcm 字节大小
int fsize = frame->nb_samples * av_get_bytes_per_sample(AV_SAMPLE_FMT_S16) * c->ch_layout.nb_channels;
auto per_frame = (uint8_t *)av_malloc(fsize);
if (per_frame == nullptr) {
  return -1;
}

//write file header.
ret = avformat_write_header(format_context,  nullptr);
if (ret < 0) {
  LOGE("av_format_write_header failed.");
  return -1;
}

  int64_t pts = 0;
while(file_size > 0) {
  memset(per_frame, 0, fsize);

  int copy_amount = file_size > fsize ? fsize : (int)file_size;
  LOGI("copy_amount: %d", copy_amount);
  if (copy_amount < fsize) {
    LOGE("per frame not enough.");
  }
  memcpy(per_frame, input_buffer, copy_amount);
  input_buffer += copy_amount;
  file_size -= copy_amount;

  ret = av_frame_make_writable(frame);
  if (ret < 0) {
    LOGE("av_frame_make_writable failed, ret: %d", ret);
    break;
  }
  
  //...
 }

step2. 重采样

c 复制代码
//resample.
LOGI("nb_samples: %d, frame_size: %d", frame->nb_samples, c->frame_size);
ret = swr_convert(swr_ctx, frame->data, frame->nb_samples, (const uint8_t**)&per_frame, frame->nb_samples);
if (ret < 0) {
  LOGE("swr_convert failed, ret: %d", ret);
  break;
}

frame->pts = pts;
pts += frame->nb_samples;

step3. 编码

c 复制代码
void encode(AVCodecContext* c, AVFrame* frame, AVPacket* pkt, AVStream* stream, AVFormatContext* format_context) {
  int ret = avcodec_send_frame(c, frame);
  if (ret < 0) {
    LOGE("avcodec_send_frame error, reason: %s", av_err2str(ret));
    return;
  }

  while (ret >= 0) {
    ret = avcodec_receive_packet(c, pkt);
    if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
      break;
    } else if (ret < 0) {
      break;
    }
    pkt->stream_index = stream->index;

    //convert time_base
    av_packet_rescale_ts(pkt, c->time_base, stream->time_base);
    //write file.
    ret = av_interleaved_write_frame(format_context, pkt);
    if (ret < 0) {
      av_packet_unref(pkt);
      break;
    }
    av_packet_unref(pkt);
  }
}

需要注意的是,编完码之后,需要送往一个空的 AVFrame 通知编码器进行 flush。

c 复制代码
// send null to encode, flush.
encode(c, nullptr, pkt, stream, format_context);
av_write_trailer(format_context);

3.2.3 释放资源

c 复制代码
// 释放资源
if (out_file) {
  fclose(out_file);
}
if (swr_ctx) {
  swr_free(&swr_ctx);
}
if (frame) {
  av_frame_free(&frame);
}
if (packet) {
  av_packet_free(&packet);
}
if (codec_ctx) {
  avcodec_free_context(&codec_ctx);
}
if (format_ctx) {
  avformat_close_input(&format_ctx);
}

四、最后

以上就是本文的所有内容了,后续精彩内容,敬请期待。

点个关注,不迷路 ^ _ ^。
跳转源码链接

相关推荐
雨白5 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹7 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空8 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭9 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日10 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安10 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑10 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟14 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡15 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0015 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体