Android音频录制实战:从PCM到MP3/AAC的完整实现

1,359 阅读9分钟

AudioRecord Vs MediaRecorder

在Android java层录音,google提供了 MediaRecorderAudioRecord

  • AudioRecord:属于 Android 音频系统的 底层 API,直接访问音频输入设备,可以输出原始的 PCM 数据
  • MediaRecorder:属于 高层 API,内部自动完成音频采集、编码、文件封装,直接输出 压缩后的音频文件(如 AAC、MP3、AMR),封装格式支持 MP4/3GP,但无法获取原始数据。
AudioRecordMediaRecorder
实时音频分析自定义音频处理✅ 适合(如语音识别、分贝计算)✅ 可插入降噪、回声消除等算法❌ 不支持不过有getMaxAmplitude 如果只要做一个音量变化的动画也可以实现
直接录制压缩文件❌ 需自行编码✅ 适合(如普通录音机)

在实际的项目开发中有这么一个需求:

在录音的过程中展示一个音量变化的动画,例如:

然后要调用一个后端接口上传实时录音无损音频,格式要求如下:

最后希望将音频文件以较小的空间存储下来

面对这个需求需要实现一个既能获取原始音频数据,又能输出压缩格式(如MP3/AAC)的录音器,为了解决MediaRecorder无法直接获取PCM的问题,需要对AudioRecord 进行封装

demo github 地址

apk下载地址

配置属性

AudioRecord 本身需要配置一些属性包括:

我们对AudioRecord的封装也需要用户设置一些配置包括:

使用建筑者模式创建一个AudioRecoder

关键代码(不包括setXXX)如下:

fun build(): AudioRecorder {
    minBufferSize =
        AudioRecord.getMinBufferSize(
            sampleRateInHz,
            channelConfig.value,
            audioFormat.value,
        )
    val audioRecord =
        AudioRecord(
            audioSource.value,
            sampleRateInHz,
            channelConfig.value,
            audioFormat.value,
            minBufferSize,
        ).apply {
handleAcousticEchoCancel(audioSessionId)
            handleAutomaticGainControl(audioSessionId)
            handleNoiseSuppress(audioSessionId)
        }
return AudioRecorder(
        sampleRateInHz,
        channelConfig,
        bitRate,
        scope,
        minBufferSize,
        encode,
        audioRecord,
        acousticEchoCanceler,
        automaticGainControl,
        noiseSuppressor,
    )
}

数据分流

录音 本身是一个不停的读入音频数据的过程,在主线程运行会堵塞主线程,因此必涉及到多线程

使用ShareFlow 传输录音收到的数据,方便数据分流到多个线程。

private fun recodeInner() {
    scope.launch {
audioRecord.startRecording()
        while (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING && isActive) {
            val buffer = ShortArray(minBufferSize)
            audioRecord.read(buffer, 0, minBufferSize)
            recordData.emit(buffer)
        }
    }
}

想要结束录音协程只有两种情况:

  1. audioRecord的状态改变了 如 调用了audioRecord.stop()
  2. 父协程被杀了,当前的协程也会被杀死。
  3. 手动释放资源。

demo中对于原始的pcm会进行两种处理

  1. 计算分贝并且分发出去
  2. 进行编码并写入文件
fun startRecording() {
    if (audioRecord.state == AudioRecord.STATE_UNINITIALIZED) return
    if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) return
    if (state != RecordState.INIT) return
    // 启动计算分贝协程
    calculateVolume()
    // 启动编码协程
    writeRecordData()
    recodeInner()
    state = RecordState.RECORDING
}

音量变化

 /**
* 参考: https://www.cnblogs.com/renhui/p/11704635.html
*/
private fun calculateVolume() {
    scope.launch {
recordData.collect { array ->
val db = 10 * log10(array.calculateRMS())
            volume.emit(db.toInt())
        }
}
}

private fun ShortArray.calculateRMS(): Double {
    var sum = 0.0
    for (sample in this) {
        sum += sample * sample
    }
    val mean = sum / this.size
    return mean
}

SoundWaveView 是一个自定义的 Android 视图组件, 用于动态显示声音波形(分贝值)的条状图。它通过颜色变化和动画效果提供视觉反馈,支持分贝值的实时更新,并标记警告阈值。

完整代码见:

SoundWaveView

实现效果:

实现细节:

  1. 条块分段逻辑
private val minDb = 0 // 最小分贝
private val maxDb = 120 // 最大分贝
private val warningDb = 80 // 警告阈值
private val barValue = 5.0 // 每个条表示的分贝值
private val barNum = ceil((maxDb - minDb) / barValue).toInt() // 条块数量
private val warningBarIndex = ceil((warningDb - minDb) / barValue).toInt() 

2. 条块样式

private val barAspectRatio = 2f  // 条块宽高比(宽度:高度 = 1:2)
private val barWeight = 0.7f     // 条块总宽度占视图宽度的 70%
private val bigScale = 2         // 警告条块的高度倍数

3. 标签

private val labelHeight: Int = run {
val s = "$minDb $warningDb $maxDb"
    val rect = Rect()
    textPaint.textSize = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_SP,
        textSize,
        context.resources.displayMetrics
)
    textPaint.getTextBounds(s, 0, s.length, rect)
    rect.height()
} // 标签的高度
private val labelPadding = 12.dpi // 标签距离条块的距离
  1. 尺寸计算

    1.   动态布局
    2.     在 onSizeChanged 中根据视图宽度计算条块宽度(barWidth)和间距(margin),公式为:
    3.  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
           val width = w - (paddingLeft + paddingRight)
           margin = width * (1 - barWeight) / (barNum + 1)
           barWidth = width * barWeight / barNum
           paddingRect.set(paddingLeft, paddingTop, w - paddingRight, h - paddingBottom)
       }
      
    4.   自适应高度
    5.     预期的高度为 :
    6.       最大的条块的高度 + 文本的高度 + 文本与条块间距离 + 用户设置的padding
    7.       当宽度改变的时候组件的高度会对应改变
    8.       例如当宽度match_parent
    9.       竖屏时候:
    10.       横屏时候,文本的大小不变条块的大小显著变大了
    11. override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
          val width = MeasureSpec.getSize(widthMeasureSpec)
          val maxBarWidth = (width - (paddingLeft + paddingRight)) * barWeight / barNum * bigScale
          val maxBarHeight = maxBarWidth * barAspectRatio
          val targetHeight = maxBarHeight + labelHeight + labelPadding + (paddingTop + paddingBottom)
          setMeasuredDimension(
              width,
              resolveSize(targetHeight.toInt(), heightMeasureSpec)
          )
      }
      
  2. 属性动画与绘制

    1.   我将这个组件的绘制分成3层:
    2.   先绘制底下一层灰色的条状
    3.   再绘制表示音量变化的那一层彩色的条状物
    4.   最后绘制底下一层标签
    5.   drawBar(canvas: Canvas, color: BarColor, @IntRange(0, 120) targetDb: Int)
    6.   表示绘制 从minDbtargetDb 的访问内的条状物 用 BarColor 的颜色
    7.  override fun onDraw(canvas: Canvas) {
           canvas.save()
           canvas.clipRect(paddingRect)
           canvas.translate(paddingLeft.toFloat(), paddingTop.toFloat())
           drawBar(canvas, BarColor.GRAY, maxDb) // 绘制一层灰色的背景
           drawBar(canvas, barColor, currentDb) // 绘制到currentDb刻度的彩色的条状物
           drawLabel(canvas) // 
           canvas.restore()
       }
      
    8.   当currentDb的值为50时候:
    9.   当currentDb的值为100时候:

为了使得组件动起来关键就是在用属性动画改变currentDb 的值

private var volume : StateFlow<Int>? = null // 获取外界最新emit的值作为动画的目标值

private fun startAnimator() {
    // clamp 是为了约束到最小值与最大值之间
    val targetDb = (volume?.value ?: minDb).clamp()
    barColor = if (targetDb > warningDb) {
        BarColor.YELLOW
} else {
        BarColor.GREEN
}
    ObjectAnimator.ofInt(this, "currentDb", currentDb, targetDb).apply {
        duration = 1000L / sampleRate
        doOnEnd {
            startAnimator()
        }
        start()
    }
}

编码

声明一个 Encoder 接口兼容不同的编码器实现

interface Encoder {
    fun release()
    fun encodeChunk(pcmData: ShortArray)
}

在 AudioRecorder 中更具用户设置的枚举类型encode 创建不同的编码器

private val encoder = Encoder.createInstance(encode, outPath, sampleRateInHz, channel.channelCount, bitRate)

fun createInstance(
    encode: AudioRecorder.Encode,
    outPutPath: String,
    sampleRate: Int,
    channelCount: Int,
    bitRate: Int,
): Encoder {
    return when (encode) {
        AudioRecorder.Encode.MP3 -> Mp3Encoder(outPutPath, sampleRate, channelCount, bitRate)
        AudioRecorder.Encode.AAC_HW -> AACHardwareEncoder(outPutPath, sampleRate, channelCount, bitRate)
    }
}

在编码协程中搜集录音数据后写入到编码器

private fun writeRecordData() {
    scope.launch {
recordData.collect { buffer ->
encoder.encodeChunk(buffer)
        }
}
}

MP3编码

Android 本身不支持mp3编码,为了实现mp3编码需要通过JNI接口调用Lame库:lame官网

下载源码后将 libmp3lame路劲 下面全部 .c 和 .h 文件复制到

我们项目的 src/main/cpp(没有就自己创建) 路劲下面

为了方便管理最好在加一层或者多层文件夹与自己写的代码和别的库区分开来

CMakeLists.txt

在cpp 路劲下面 新建一个

写入

cmake_minimum_required(VERSION 3.22.1) # 指定 CMake 最低版本为 3.22.1

project("mp3encoder") # 定义项目名称为 "mp3encoder"

file(GLOB LAME_SOURCES "lame/*.c") # 设置 lame 源码路径

add_library(lame STATIC ${LAME_SOURCES}) # 编译 LAME 为静态库

add_library(MP3Encoder SHARED LameEncoder.cpp) # 编译 JNI 接口为动态库 LameEncoder.cpp 就是自己写的调用Lame的接口
 
include_directories(lame) # 包含 lame头文件

target_link_libraries(MP3Encoder android lame) # 链接依赖

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DSTDC_HEADERS") # 强制代码使用标准头文件路径

LameEncoder.cpp

核心实现原理

  1. LAME 库的初始化与配置 通过 lame_init() 创建编码器实例,设置采样率、声道数、比特率等参数,并调用 lame_init_params() 完成初始化。
  2. 分块编码机制 音频数据分批次输入,通过 lame_encode_buffer(单声道)或 lame_encode_buffer_interleaved(立体声)进行编码,最后调用 lame_encode_flush 写入尾部数据。
  3. JNI 封装 将 C++ 的 LameEncoder 类暴露给 Java 层,通过 jlong 指针保存 Native 对象地址,实现跨语言调用。

代码结构与关键模块

1. LameEncoder 类(C++)
class LameEncoder {
private:
    FILE* outputFile;       // 输出文件指针
    lame_t lameClient;      // LAME 编码器实例
    unsigned char* mp3Buffer; // MP3 数据缓冲区
    int bufferSize;         // 缓冲区大小
    bool initialized;       // 初始化状态
    public:LameEncoder(const char* outputPath, int sampleRate, int channels, int bitRate);
    ~LameEncoder();
    bool encodeChunk(const short* pcmData, int sampleCount);int channelCount;
};
  • 构造函数 打开输出文件,初始化 LAME 参数,分配缓冲区。
LameEncoder::LameEncoder(...) {
    outputFile = fopen(outputPath, "wb");
    lameClient = lame_init();lame_set_in_samplerate(lameClient, sampleRate);
    // ... 其他参数设置
    mp3Buffer = new unsigned char[bufferSize];
}
  • 析构函数 刷新剩余数据、释放资源。
LameEncoder::~LameEncoder() {
    int finalSize = lame_encode_flush(...);
    fwrite(...); // 写入最终数据
    lame_close(lameClient);
    delete[] mp3Buffer;
}
  • 分块编码方法 根据声道数选择编码函数,处理 PCM 数据。
bool encodeChunk(const short* pcmData, int sampleCount) {
    if (channelCount == 1) {
        encodedSize = lame_encode_buffer(...);
    } else {
        encodedSize = lame_encode_buffer_interleaved(...);
    }
    fwrite(mp3Buffer, 1, encodedSize, outputFile);
}
2. JNI 接口函数
  • 创建编码器对象

LameEncoder 指针可以强转成一个 jlong 传递到 java层 持有,以便下次调用这个对象,类似的写法要注意内存泄露和悬空指针的风险

extern "C" JNIEXPORT jlong JNICALL
Java_com_example_lame_LameEncoder_createEncoder(...) {
    const char* outPath = env->GetStringUTFChars(...);
    auto* encoder = new LameEncoder(...);
    return reinterpret_cast<jlong>(encoder);
}
  • 编码数据块

将java层传递过来的 long 值强转成LameEncoder指针就可以正常访问刚刚创建的编码器对象了

extern "C" JNIEXPORT jboolean JNICALL
Java_com_example_lame_LameEncoder_encodeChunk(... jlong encoder_ptr, jshortArray pcmData) {
    auto* encoder = reinterpret_cast<LameEncoder*>(encoder_ptr);
    jshort* data = env->GetShortArrayElements(...);
    bool success = encoder->encodeChunk(...);
    env->ReleaseShortArrayElements(...);
}
  • 释放资源
extern "C" JNIEXPORT void JNICALL
Java_com_example_lame_LameEncoder_releaseEncoder(...) {
    delete encoder;
}

JNI接口名称那么长

回顾一下在LameEncoder 写的JNI接口

Java_com_example_lame_LameEncoder_createEncoder

Java_com_example_lame_LameEncoder_encodeChunk

Java_com_example_lame_LameEncoder_releaseEncoder

它们的名称有一个特点就是超级长

JNI 接口名称之所以冗长,是因为其遵循严格的命名规则以确保唯一性和正确性。

JNI通过全限定路径将Java方法与Native函数一一映射,避免不同包、类或重载方法之间的冲突。

例如

Java_com_example_lame_LameEncoder_createEncoder

名称中的Java(文件名) com_example_audio(包名)、LameEncoder(类名)、createEncoder(方法名)共同保证了全局唯一性。

为了在java层调用这个Java_com_example_lame_LameEncoder_createEncoder

我们需要在指定位置建立指定类声明指定函数 如下

并且确保在调用这些函数之前声明了System.loadLibrary("MP3Encoder")

当我们在别的java类中想要调用cpp代码的时候就可以用创建 LameEncoder.kt 对象,然后调用对应的方法就行了

class Mp3Encoder(
    outPutPath: String,
    sampleRate: Int,
    channelCount: Int,
    bitRate: Int
) : Encoder {
    private val encoder = LameEncoder()
    private val ptr = encoder.createEncoder(outPutPath, sampleRate, channelCount, bitRate)
    private var state = State.RUNNING

override fun release() {
        require(state == State.RUNNING)
        encoder.releaseEncoder(ptr)
        state = State.RELEASE
}

    override fun encodeChunk(pcmData: ShortArray) {
        require(state == State.RUNNING)
        encoder.encodeChunk(ptr, pcmData)
    }

    enum class State {
        RUNNING,
        RELEASE
}
}

AAC编码

Android 的 MediaCodec 本身支持AAC编码就没有再导入其他的第三方库

下面的代码实现了一个基于 Android MediaCodec 的 AAC 硬件编码框架demo,将 PCM 音频数据实时编码为 AAC 格式,并封装为 m4a

  1. AACHardwareEncoder(入口类)

职责

  • 作为编码器的入口,接收 PCM 数据分块并分发给编码器。
  • 管理编码器的生命周期(初始化、释放)。

关键代码解析

class AACHardwareEncoder(
    outPutPath: String,
    sampleRate: Int,
    channelCount: Int,
    bitRate: Int,
) : Encoder {
    private val provider = AudioChunkProvider()

    @OptIn(DelicateCoroutinesApi::class)
    private val encoder = AACMediaCodecEncoder(outPutPath, sampleRate, channelCount, bitRate, GlobalScope)
    private var totalSize = 0.0
    private val pcmShortRate = sampleRate * channelCount

    init {
        encoder.setPcmData(provider)
        encoder.start()
    }

    override fun release() {
        provider.release()
    }

    override fun encodeChunk(pcmData: ShortArray) {
        totalSize += pcmData.size
        val shortsInfo = ShortsInfo(pcmData, 0, pcmData.size, getSampleTime(), 0)
        provider.send(shortsInfo)
    }

    private fun getSampleTime() = (totalSize * 1_000_000 / pcmShortRate).roundToLong()
}
  • 初始化:创建 AudioChunkProvider(复制缓冲与分发数据)和 AACMediaCodecEncoder(编码器),启动编码线程。
  • 数据分块:将输入的 PCM 数据封装为 ShortsInfo(含时间戳和标志位),通过 provider.send 发送到缓冲区。
  • 结束信号:调用 release() 时调用AudioChunkProvider,通知编码器停止。
  1. AudioChunkProvider

职责

  • 作为 BaseChunkBufferProvider 的子类,管理 PCM 数据块的缓冲和分发。
  • 编码器运行在后台线程,且编码器的设计模式要求数据按照生产者 - 消费者的模式获取,使用 Kotlin 协程的 Channel 实现生产者-消费者模型

关键代码解析

class AudioChunkProvider : BaseChunkBufferProvider() {
    private val channel = Channel<ShortsInfo>(Int.MAX_VALUE)

    fun send(shortsInfo: ShortsInfo) {
        channel.trySend(shortsInfo)
    }

    override suspend fun fetchNextRawChunk(): ShortsInfo? {
        val shortsInfo = channel.receive().takeUnless { it.flags == BUFFER_FLAG_END_OF_STREAM }
if (shortsInfo == null) channel.cancel()
        return shortsInfo
    }

    override fun onChunkReleased() {
        channel.trySend(ShortsInfo(ShortArray(0), flags = BUFFER_FLAG_END_OF_STREAM ))
    }
}
  • Channel 缓冲:通过 Channel 异步接收 PCM 数据块,容量为 Int.MAX_VALUE 避免阻塞。
  • 结束条件:当收到 BUFFER_FLAG_END_OF_STREAM 标志时停止数据拉取。并发送null 表示后续无数据
  1. BaseChunkBufferProvider (缓冲基类)

职责

  • 提供统一的缓冲区管理逻辑,处理分块数据的合并与切割。
  • 确保数据按 MediaCodec 所需的缓冲区大小正确填充。

关键代码解析

abstract class BaseChunkBufferProvider : PcmBufferProvider {
    private var currentChunk: ShortsInfo? = nulloverride
    suspend fun getBuffer(size: Int): ShortsInfo {
        val buffer = ShortArray(size)
        var bufferOffset = 0
        while (bufferOffset < size) {
            val chunk = getAvailableChunk() ?: break
            val copySize = minOf (remaining, chunk.size)
            System.arraycopy(...)
            bufferOffset += copySize
        }
        return ShortsInfo(buffer,... flags = if (bufferOffset < size) BUFFER_FLAG_END_OF_STREAM else 0)
    }
}
  • 缓冲区合并:若当前数据块不足,自动拉取新数据块填充缓冲区。
  • 内存复用:通过 System.arraycopy 高效合并多个小数据块
  • 结束标记: 当无可以数据块(getAvailableChunk 放回空)导致实际提交的缓冲区大小小于预期值时候,返回的数据块标记位设置为结束
  1. AACMediaCodecEncoder (编码器核心)

职责

  • 配置 MediaCodecMediaMuxer,实现 PCM 到 AAC 的编码。
  • 处理输入/输出缓冲区的异步操作。

关键代码解析

class AACMediaCodecEncoder(...) {
    private val codec = MediaCodec.createEncoderByType("audio/mp4a-latm")
    private val muxer = MediaMuxer(outPutPath, ...)
    val progress = MutableStateFlow<Long>(0)
    fun start() {
        codec.start()
        scope.launchIO {
while (!isEndOfEncoded) {
                submitPcmToCodec()
                processOutputBuffer()
            }
            progress.emit(EOF)
            release()
        }
}

    private suspend fun submitPcmToCodec() {
        val index = codec.dequeueInputBuffer(0)
        val buffer = codec.getInputBuffer(index)
        val pcmShortInfo = provider.getBuffer(buffer.remaining())
        buffer.put(pcmShortInfo.shorts)
        codec.queueInputBuffer(...)
    }

    private fun processOutputBuffer() {
        when (val index = codec.dequeueOutputBuffer(...)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
    muxerTrackIndex = muxer.addTrack(codec.outputFormat)
                muxer.start()
            }
        } else -> {
val outputBuffer = codec.getOutputBuffer(index)
            muxer.writeSampleData(...)
}
}
    private fun release()
}
  • 编码流程

    • 配置 MediaCodec:设置 AAC 编码参数(采样率、声道数、比特率)。
    • 输入缓冲区处理:从 BaseChunkBufferProvider 获取 PCM 数据,提交给 MediaCodec
    • 输出缓冲区处理:将编码后的 AAC 数据写入 MediaMuxer 生成 MP4 文件。

注意事项

  1. 结束信号处理 必须发送 BUFFER_FLAG_END_OF_STREAM 标志,确保 MediaCodec 正确刷新剩余数据。 编码是异步工作,当我们往**AudioChunkProvider** ****发入数据库后该数据不会被立刻处理,通常这个数据块比较小会与多个数据块合并在一起后被 MediaCodec 送入输入缓冲区,MediaCodec 本身又开了一个线程用来做具体的编码工作,当编码结束后会把数据写入到输出缓冲区,等待我们取出,取出后交给MediaMuxer 处理这才写入了文件

从我们送入数据块到被写入文件,中间耗时数秒,不能粗暴的结束释放编码器资源,这会使得部分数据未写入文件,导致音频文件损坏

  1. 时间戳计算

pcmShortRate = sampleRate * channelCount 表示每秒多少个字节

因此 totalSize / pcmShortRate 就表示这个数据库的时间戳 单位是毫秒

并且为了保证时间戳 要先 * 1_000_000

private val pcmShortRate = sampleRate * channelCount
private fun getSampleTime() = (totalSize * 1_000_000 / pcmShortRate).roundToLong()