音视频开发之旅——实现录音器、音频格式转换器和播放器(PCM文件转换为WAV文件、使用LAME编码MP3文件)(Android)

本文主要讲解的是实现录音器音频转换器播放器,在实现过程中需要把PCM文件转换为WAV文件,同时需要使用上一篇文章交叉编译出来的LAME库编码MP3文件。本文基于Android平台,示例代码如下所示:

AndroidAudioDemo

Android系列:

音视频开发之旅——音频基础概念、交叉编译原理和实践(LAME的交叉编译)(Android)

iOS系列:

音视频开发之旅——音频基础概念、交叉编译原理和实践(LAME的交叉编译)(iOS)

项目主要分为三个部分:录音器音频格式转换器播放器

准备工作

需求需要在上一篇文章的示例代码上去实现,为了代码规范,我们先重构下之前的代码,主要是以下三个过程:

  1. 重命名androidaudiodemo.cpp

  2. CMakeLists.txt修改动态库的名称。

  3. 调整相关的Kotlin和C++代码。

重命名androidaudiodemo.cpp

androidaudiodemo.cpp重命名为mp3_encoder.cpp

CMakeLists.txt修改动态库的名称

CMAKE_PROJECT_NAME修改为MP3Encoder,这里的CMAKE_PROJECT_NAME指的是顶级项目的名称,它是指project命令指定的项目名称,所以之前生成的动态库名称为libandroidaudiodemo.so,修改后生成的动态库名称为libMP3Encoder.so。修改后的代码如下所示:

cmake_minimum_required(VERSION 3.22.1)

project("androidaudiodemo")

add_library(
        MP3Encoder
        SHARED
        mp3_encoder.cpp
        lame/reservoir.c
        lame/mpglib_interface.c
        lame/machine.h
        lame/fft.h
        lame/set_get.c
        lame/quantize_pvt.h
        lame/psymodel.h
        lame/newmdct.c
        lame/id3tag.h
        lame/lame-analysis.h
        lame/id3tag.c
        lame/reservoir.h
        lame/lameerror.h
        lame/set_get.h
        lame/quantize.c
        lame/fft.c
        lame/l3side.h
        lame/newmdct.h
        lame/quantize.h
        lame/gain_analysis.c
        lame/encoder.c
        lame/lame.c
        lame/bitstream.c
        lame/quantize_pvt.c
        lame/presets.c
        lame/bitstream.h
        lame/encoder.h
        lame/gain_analysis.h
        lame/lame_global_flags.h
        lame/psymodel.c
        lame/lame.h
        lame/tables.c
        lame/tables.h
        lame/takehiro.c
        lame/util.c
        lame/util.h
        lame/vbrquantize.c
        lame/vbrquantize.h
        lame/VbrTag.c
        lame/VbrTag.h
        lame/version.c
        lame/version.h
)

target_link_libraries(
        MP3Encoder
        android
        log
)

cmake_minimum_required命令

需要最低版本cmake

project命令

设置项目的名称,并且将其存储在PROJECT_NAME变量中。如果从顶级(top-level)的CMakeLists.txt调用时,还会将项目名称存储在CMAKE_PROJECT_NAME变量中。

add_library命令

使用指定的源文件(例如:LAME相关的.h和.c文件)将库添加到项目中。语法如下所示:

add_library(<name> [<type>] [EXCLUDE_FROM_ALL] <sources>...)
  • name:库名称,并且在项目中必须全局唯一。

  • type:这是个可选参数,有STATICSHAREDMODULE三个类型,如果未给定这个参数,就会根据BUILD_SHARED_LIBS变量的值,默认值为STATIC或者SHARED

  • EXCLUDE_FROM_ALL:这个参数会自动设置。

  • source:指定的源文件,例如:LAME相关的.h和.c文件。

STATIC

创建的是静态库

  • 文件扩展名:在Unix-like系统中为.a,在Windows系统中为.lib。

  • 链接方式:在编译时链接到使用它的目标。

  • 适用场景:一般为小型程序和一些避免使用动态链接的场景。

SHARED

创建的是动态库

  • 文件扩展名:在Unix-like系统中为.so,在Windows系统中为.dll。

  • 链接方式:链接到使用它的目标,运行时动态加载。

  • 适用场景:需要共享代码。

MODULE

创建的是动态库

  • 文件扩展名:在Unix-like系统中为.so,在Windows系统中为.dll。

  • 链接方式:不直接链接到使用它的目标(和SHARED不同的地方),运行时动态加载。

  • 适用场景:一般为插件系统,需要共享代码。

target_link_libraries命令

指定链接给定的目标或者其从属对象时需要使用的库(libraries)或者标志(flags)。语法如下所示:

target_link_libraries(<target> ... <item>... ...)
  • target:这个目标必须是通过add_executable命令或者add_library命令创建的,并且不能是ALIAS目标。

  • item:它有可能是库目标名称库文件的完整路径链接标志生成器表达式

调整相关的Kotlin和C++代码

新建LameUtils类用于存放使用LAME的函数,代码如下所示:

package com.tanjiajun.androidaudiodemo.utils

/**
 * Created by TanJiaJun on 2024/3/28.
 */
object LAMEUtils {

    init {
        System.loadLibrary("MP3Encoder")
    }

    /**
     * 获取当前LAME版本
     *
     * @return 当前LAME版本
     */
    external fun getLameVersion(): String

}

这里要注意的是,通过反编译后的Java代码可知,System.loadLibrary函数是在LAMEUtils类的静态代码块中,所以这个函数只会在LAMEUtils类第一次加载时执行一次,之后就不会再执行。反编译后的Java代码如下所示:

package com.tanjiajun.androidaudiodemo.utils;

import kotlin.Metadata;
import org.jetbrains.annotations.NotNull;

@Metadata(
   mv = {1, 9, 0},
   k = 1,
   d1 = {"\u0000\u0012\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0000\bÆ\u0002\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002J\t\u0010\u0003\u001a\u00020\u0004H\u0086 ¨\u0006\u0005"},
   d2 = {"Lcom/tanjiajun/androidaudiodemo/utils/LAMEUtils;", "", "()V", "getLameVersion", "", "app_debug"}
)
public final class LAMEUtils {
   @NotNull
   public static final LAMEUtils INSTANCE;

   @NotNull
   public final native String getLameVersion();

   private LAMEUtils() {
   }

   static {
      LAMEUtils var0 = new LAMEUtils();
      INSTANCE = var0;
      System.loadLibrary("MP3Encoder");
   }
}

不过其实如果System.loadLibrary多次加载同一个本地库也只是会加载一次,因为JVM会在其内部维护一个加载库的缓存,如果尝试多次加载,JVM不会重新加载它,只是会增加库的引用次数。

最后,在MainActivity中像如下调用即可:

LAMEUtils.getLameVersion()

经过上面的修改后,我们开始进行新需求的开发。

录音器

我们需要使用AudioRecord相关的函数,它在android.media包中,用于录制来自麦克风、耳机麦克风或者其他音频输入源的音频。首先,我们要思考录音器大概需要有什么功能?大概需要录制音频录音时长暂停录音重置释放资源输出数据(PCM源数据和PCM文件)这几个功能。我们新建AudioRecorder来实现这些需求,代码如下所示:

package com.tanjiajun.androidaudiodemo.utils

import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import java.io.File
import java.io.FileOutputStream


/**
 * Created by TanJiaJun on 2024/3/20.
 *
 * 录音器
 */
class AudioRecorder private constructor(
    private val minBufferSize: Int = 0,
    private val audioRecord: AudioRecord,
    private val sampleRateInHz: Int,
    private val audioFormat: AudioRecorderFormat,
    private val channelConfig: AudioRecorderChannelConfig,
    private val acousticEchoCanceler: AcousticEchoCanceler?,
    private var automaticGainControl: AutomaticGainControl?,
    private val noiseSuppressor: NoiseSuppressor?,
    private val listener: AudioRecordListener?
) {

    private val shortArrays: MutableList<ShortArray> by lazy { mutableListOf() }
    private val floatArrays: MutableList<FloatArray> by lazy { mutableListOf() }
    private var byteLength: Long = 0L

    /**
     * 录制音频
     */
    suspend fun record() {
        if (audioRecord.state == AudioRecord.STATE_UNINITIALIZED) {
            return
        }
        if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
            throw RuntimeException("Cannot be call record() while recording.")
        }
        withIO {
            audioRecord.startRecording()
            while (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
                var byteCount: Int
                if (audioFormat == AudioRecorderFormat.PCM_16BIT) {
                    val shortArray = ShortArray(minBufferSize)
                    byteCount = audioRecord.read(shortArray, 0, shortArray.size) * 2
                    withMain {
                        shortArrays.add(shortArray)
                    }
                } else {
                    val floatArray = FloatArray(minBufferSize)
                    byteCount = audioRecord.read(
                        floatArray,
                        0,
                        floatArray.size,
                        AudioRecord.READ_BLOCKING
                    ) * 4
                    withMain {
                        floatArrays.add(floatArray)
                    }
                }
                if (byteCount <= 0) {
                    return@withIO
                }
                withMain {
                    byteLength += byteCount
                    val durationInSec: Long = AudioUtils.getAudioDurationInSec(
                        byteLength = byteLength,
                        sampleRateInHz = sampleRateInHz,
                        bitDepth = AudioUtils.getBitDepthByAudioFormat(audioFormat.value),
                        channelCount = AudioUtils.getChannelCountByChannelConfig(channelConfig.value)
                    )
                    listener?.onRecording(durationInSec)
                }
            }
        }
    }

    /**
     * 暂停录音
     */
    fun stop() {
        if (audioRecord.state == AudioRecord.STATE_UNINITIALIZED) {
            return
        }
        audioRecord.stop()
    }

    private fun clearRecordedData() {
        if (audioFormat == AudioRecorderFormat.PCM_16BIT) {
            shortArrays.clear()
        } else {
            floatArrays.clear()
        }
    }

    /**
     * 重置
     */
    fun reset() {
        clearRecordedData()
        byteLength = 0L
    }

    /**
     * 释放资源
     */
    fun release() {
        clearRecordedData()
        byteLength = 0L
        audioRecord.release()
        acousticEchoCanceler?.release()
        automaticGainControl?.release()
        noiseSuppressor?.release()
    }

    /**
     * 是否正在录音
     *
     * @return 是否正在录音
     */
    fun isRecording(): Boolean =
        audioRecord.state == AudioRecord.RECORDSTATE_RECORDING

    /**
     * 是否存在已经录制的音频
     *
     * @return 是否存在已经录制的音频
     */
    fun hasRecordedAudio(): Boolean =
        shortArrays.isNotEmpty() || floatArrays.isNotEmpty()

    /**
     * 得到位深度为16bit的PCM音频
     *
     * @return 音频数据
     */
    fun getRecordedDataFor16BitPCM(): List<ShortArray> =
        shortArrays.toList()

    /**
     * 得到位深度为32bit的PCM音频
     *
     * @return 音频数据
     */
    fun getRecordedDataFor32BitPCM(): List<FloatArray> =
        floatArrays.toList()

    /**
     * 将录音数据保存成文件
     *
     * @param outputPCMFilePath 输出的PCM文件路径
     * @return 输出的文件
     */
    suspend fun saveDataAsPCM(outputPCMFilePath: String): File? {
        if (outputPCMFilePath.isEmpty()) {
            return null
        }
        return withIO {
            val outputPCMFile = File(outputPCMFilePath)
            if (!outputPCMFile.exists()) {
                outputPCMFile.parentFile?.mkdirs()
                outputPCMFile.createNewFile()
            }
            FileOutputStream(outputPCMFile).use { fileOutputStream ->
                BufferedOutputStream(fileOutputStream).use { bufferedOutputStream ->
                    if (audioFormat == AudioRecorderFormat.PCM_16BIT) {
                        shortArrays.forEach {
                            bufferedOutputStream.write(convertShortArrayToByteArray(it))
                        }
                    } else {
                        floatArrays.forEach {
                            bufferedOutputStream.write(convertFloatArrayToByteArray(it))
                        }
                    }
                }
            }
            outputPCMFile
        }
    }

    private fun convertShortArrayToByteArray(src: ShortArray): ByteArray =
        ByteArray(src.size * 2).apply {
            src.forEachIndexed { index, value: Short ->
                set(index * 2, value.toByte())
                set(index * 2 + 1, (value.toInt() shr 8).toByte())
            }
        }

    private fun convertFloatArrayToByteArray(src: FloatArray): ByteArray =
        convertShortArrayToByteArray(ShortArray(src.size).apply {
            src.forEachIndexed { index, value: Float ->
                set(index, (value * 32768).toInt().toShort())
            }
        })

    class Builder {

        private var minBufferSize: Int = 0
        private lateinit var audioRecord: AudioRecord
        private var audioSource: AudioRecorderSource = AudioRecorderSource.MIC
        private var sampleRateInHz: Int = 44100
        private var audioFormat: AudioRecorderFormat = AudioRecorderFormat.PCM_16BIT
        private var channelConfig: AudioRecorderChannelConfig = AudioRecorderChannelConfig.STEREO
        private var addAcousticEchoCanceler: Boolean = false
        private var addAutomaticGainControl: Boolean = false
        private var addNoiseSuppressor: Boolean = false
        private var listener: AudioRecordListener? = null

        private var acousticEchoCanceler: AcousticEchoCanceler? = null
        private var automaticGainControl: AutomaticGainControl? = null
        private var noiseSuppressor: NoiseSuppressor? = null

        /**
         * 设置音频来源
         */
        fun setAudioSource(audioSource: AudioRecorderSource): Builder {
            this.audioSource = audioSource
            return this
        }

        /**
         * 设置采样率
         */
        fun setSampleRateInHz(sampleRateInHz: Int): Builder {
            this.sampleRateInHz = sampleRateInHz
            return this
        }

        /**
         * 设置音频格式
         */
        fun setAudioFormat(audioFormat: AudioRecorderFormat): Builder {
            this.audioFormat = audioFormat
            return this
        }

        /**
         * 设置声道配置
         */
        fun setChannelConfig(channelConfig: AudioRecorderChannelConfig): Builder {
            this.channelConfig = channelConfig
            return this
        }

        /**
         * 添加声学回声消除器
         */
        fun addAcousticEchoCanceler(): Builder {
            addAcousticEchoCanceler = true
            return this
        }

        /**
         * 添加自动增益控制
         */
        fun addAutomaticGainControl(): Builder {
            addAutomaticGainControl = true
            return this
        }

        /**
         * 添加噪音抑制器
         */
        fun addNoiseSuppressor(): Builder {
            addNoiseSuppressor = true
            return this
        }

        /**
         * 设置录音监听者
         */
        fun setAudioRecordListener(listener: AudioRecordListener): Builder {
            this.listener = listener
            return this
        }

        @SuppressLint("MissingPermission")
        fun build(): AudioRecorder {
            minBufferSize =
                AudioRecord.getMinBufferSize(
                    sampleRateInHz,
                    channelConfig.value,
                    audioFormat.value
                )
            audioRecord = AudioRecord(
                audioSource.value,
                sampleRateInHz,
                channelConfig.value,
                audioFormat.value,
                minBufferSize
            ).apply {
                handleAcousticEchoCancel(audioSessionId)
                handleAutomaticGainControl(audioSessionId)
                handleNoiseSuppress(audioSessionId)
            }
            return AudioRecorder(
                minBufferSize,
                audioRecord,
                sampleRateInHz,
                audioFormat,
                channelConfig,
                acousticEchoCanceler,
                automaticGainControl,
                noiseSuppressor,
                listener
            )
        }

        private fun handleAcousticEchoCancel(audioSessionId: Int) {
            if (!addAcousticEchoCanceler) {
                return
            }
            if (!AcousticEchoCanceler.isAvailable()) {
                return
            }
            acousticEchoCanceler = AcousticEchoCanceler.create(audioSessionId)
            acousticEchoCanceler?.enabled = true
        }

        private fun handleAutomaticGainControl(audioSessionId: Int) {
            if (!addAcousticEchoCanceler) {
                return
            }
            if (!AutomaticGainControl.isAvailable()) {
                return
            }
            automaticGainControl = AutomaticGainControl.create(audioSessionId)
            automaticGainControl?.enabled = true
        }

        private fun handleNoiseSuppress(audioSessionId: Int) {
            if (!addNoiseSuppressor) {
                return
            }
            if (!NoiseSuppressor.isAvailable()) {
                return
            }
            noiseSuppressor = NoiseSuppressor.create(audioSessionId)
            noiseSuppressor?.enabled = true
        }

    }

    enum class AudioRecorderSource(val value: Int) {

        @Description("麦克风音频源")
        MIC(MediaRecorder.AudioSource.MIC),

    }

    enum class AudioRecorderFormat(val value: Int) {

        @Description("PCM每个采样16位,保证由设备支持")
        PCM_16BIT(AudioFormat.ENCODING_PCM_16BIT),

        @Description("PCM每个采样单精度浮点")
        PCM_FLOAT(AudioFormat.ENCODING_PCM_FLOAT)

    }

    enum class AudioRecorderChannelConfig(val value: Int) {

        @Description("单声道")
        MONO(AudioFormat.CHANNEL_IN_MONO),

        @Description("立体声声道")
        STEREO(AudioFormat.CHANNEL_IN_STEREO)

    }

    interface AudioRecordListener {

        /**
         * 正在录音
         *
         * @param durationInSec 音频时长,单位:秒
         */
        fun onRecording(durationInSec: Long)

    }

    private companion object {
        const val TAG = "AudioRecorder"
    }

}

总体设计

AudioRecorder的创建用到了建造者模式,因为这个对象需要多个参数去创建,同时具备一定的灵活性,也就是说有可能某些参数是不需要的,对象的创建过程与其表示需要分离。在创建对象的时候,一些必要的参数都会有默认数值。在设置音频源位深度声道数这些参数的时候用了枚举类进行约束,避免传入不符合的数值。

record函数

根据官方文档描述,录制不同的位深度音频,需要调用对应的record重载函数,如下所示:

如果要录制位深度为16bit的音频,需要写入到short数组,官方描述也可以写入到byte数组,但是不推荐使用,代码如下所示:

// AudioRecord.java
// 不推荐使用该函数录制位深度为16bit的音频
public int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes,
            @ReadMode int readMode) {
    // 省略部分代码

    return native_read_in_byte_array(audioData, offsetInBytes, sizeInBytes,
            readMode == READ_BLOCKING);
}

public int read(@NonNull short[] audioData, int offsetInShorts, int sizeInShorts) {
        return read(audioData, offsetInShorts, sizeInShorts, READ_BLOCKING);
}

如果是要录制位深度为32bit的音频,需要写入到float数组,代码如下所示:

// AudioRecord.java
public int read(@NonNull float[] audioData, int offsetInFloats, int sizeInFloats,
        @ReadMode int readMode) {
    // 省略部分代码

    return native_read_in_float_array(audioData, offsetInFloats, sizeInFloats,
                readMode == READ_BLOCKING);
    }

计算录音时长

录音时长可以通过音频比特率总字节长度计算出来,代码如下所示:

// AudioUtils.kt
/**
 * 得到音频时长,单位:秒。
 *
 * @param byteLength 字节长度
 * @param sampleRateInHz 采样率,单位:赫兹
 * @param bitDepth 位深度
 * @param channelCount 声道数
 * @return 音频时长,单位:秒
 */
@JvmStatic
fun getAudioDurationInSec(
    byteLength: Long,
    sampleRateInHz: Int,
    bitDepth: Int,
    channelCount: Int
): Long {
    val bitRate = sampleRateInHz * bitDepth * channelCount
    return byteLength * 8 / bitRate
}

采样率(单位:赫兹) * 位深度 * 声道数 = 比特率

因为比特率用于衡量音频数据单位时间内的容量大小,也就是一秒时间内的比特数目,所以:

总字节长度 * 8 / 比特率 = 音频时长

AcousticEchoCanceler

AcousticEchoCanceler声学回声消除器,简称AEC,它是一种音频预处理器,可以从捕获的音频信号中去除从远程方接收到的信号的影响。AEC用于语音通信应用,例如:语音聊天、视频会议或者SIP呼叫,在这些应用中,从远程方接收到的信号中存在着回声和显著的延迟会令人非常不按。它通常和**嗓音抑制器(NS)**结合使用。

在启用之前检查下设备是否支持AEC,然后创建AcousticEchoCanceler,并且将其通过音频会话id附加到指定的音频。

AutomaticGainControl

AutomaticGainControl自动增益控制,简称AGC,它是一种音频预处理器,可以通过升高或者降低麦克风的输入来自动标准化捕获信号的输出,以匹配预设电平,从而使输出信号电平几乎恒定。AGC用于输入信号动态范围并不重要,但是需要恒定的强捕获电平的应用。

我们常用**dBFS(分贝全幅波形)**来表示音频信号的电平,理想的录音电平通常在-12dBFS~-18dBFS之间,以确保有足够的动态范围,避免信号过载或者失真。

在启用之前检查下设备是否支持AGC,然后创建AutomaticGainControl,并且将其通过音频会话id附加到指定的音频。

NoiseSuppressor

NoiseSuppressor噪声抑制器,简称NS,它是一种从捕获的音频信号中去除背景噪声的音频预处理器。噪声可以分为静止的,例如:汽车或者飞机发动机声音,它们是有一定规律的;也可以分为非静止的,例如:其他人的对话,它们没什么规律。NS用于语音通信应用,例如:语音聊天、视频会议或者SIP呼叫。

在启用之前检查下设备是否支持NS,然后创建NoiseSuppressor,并且将其通过音频会话id附加到指定的音频。

输出数据

该录音器支持输出两种数据,分别是PCM源数据PCM文件

PCM源数据会根据音频位深度返回不同类型的List,位深度为16bit会返回short数组的List,位深度为32bit会返回float数组的List,目的是方便AudioTrack回放音频,因为它也是根据位深度不同,有不一样的写数据函数,这个后面会提到。要注意的是,它们都通过调用可变的MutableList的toList函数,返回的是一个新的不可变的List,这样做的目的是防止使用AudioRecorder的类因为持有这个List的引用,导致可以修改它的数据,出现脏数据,影响到AudioRecorder的表现,而且也不利于调试代码,这种暴露对象引用的做法是违反了封装原则,它会使得对象的内部状态容易被外部状态影响,从而破坏对象的一致性和状态安全。

saveDataAsPCM函数

我们看到该函数写入文件的时候用到了FileOutputStream(文件输出流),并且使用BufferedOutputStream(缓冲输出流)提高写入速度,通过write函数将数据写入缓冲区,当缓冲区满时才一次性写入文件,减少磁盘的写入次数,从而提高效率。我们在读取文件的时候也可以使用相对应的FileInputStream(文件输入流)BufferedInputStream(缓冲输入流),它是通过read函数一次性从文件读取多个字节到缓冲区中,后续的读取操作实际上是从缓冲区读取数据,减少磁盘的读取次数,从而提高效率。

我们还看到该函数使用到use函数,它可以帮我们正确地关闭使用的资源(无论是否产生异常),而且方便我们查看异常,避免异常屏蔽。我们使用Kotlin处理资源的时候要优先考虑使用这个函数,源码如下所示:

// Closeable.kt
@InlineOnly
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var exception: Throwable? = null
    try {
        return block(this)
    } catch (e: Throwable) {
        exception = e
        throw e
    } finally {
        when {
            apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
            this == null -> {}
            exception == null -> close()
            else ->
                try {
                    close()
                } catch (closeException: Throwable) {
                    // cause.addSuppressed(closeException) // ignored here
                }
        }
    }
}

@SinceKotlin("1.1")
@PublishedApi
internal fun Closeable?.closeFinally(cause: Throwable?) = when {
    this == null -> {}
    cause == null -> close()
    else ->
        try {
            close()
        } catch (closeException: Throwable) {
            cause.addSuppressed(closeException)
        }
}

我们在使用输入流(InputStream)输出流(OutputStream)java.sql.Connection的时候,都要手工调用close函数来关闭资源,在Java 7之前采用的是try-finally语句来关闭资源,但是在多个资源的时候,try-finally语句就需要不断嵌套,可读性很差,而且就算这样做能正确地关闭了资源,但是这种写法还是存在着不足,try块和finally块都有可能会抛出异常,如果同时抛出异常,那么第二个异常会完全抹掉第一个异常,在异常堆栈轨迹中是完全找不到第一个异常的记录,第一个异常被屏蔽了,这导致调试变得非常复杂,示例代码如下所示:

public void copyFile() throws IOException {
    // 输入文件路径
    String inputFilePath = "input.txt";
    // 输出文件路径
    String outputFilePath = "output.txt";

    InputStream fileInputStream = new FileInputStream(inputFilePath);
    try {
        InputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
        try {
            OutputStream fileOutputStream = new FileOutputStream(outputFilePath);
            try {
                OutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
                try {
                    byte[] buffer = new byte[1024];
                    int length;
                    while((length = bufferedInputStream.read(buffer)) > 0) {
                        bufferedOutputStream.write(buffer, 0, length);
                    }
                } finally {
                    bufferedOutputStream.close();
                }
            } finally {
                fileOutputStream.close();
            }
        } finally {
            bufferedInputStream.close();
        }
    } finally {
        fileInputStream.close();
    }
}

在Java 7之后引入了try-with-resources语句,它可以解决上面所说的所有问题,使用它优化上面的示例代码,示例代码如下所示:

public void copyFile() throws IOException {
    // 输入文件路径
    String inputFilePath = "input.txt";
    // 输出文件路径
    String outputFilePath = "output.txt";
    try (
        InputStream fileInputStream = new FileInputStream(inputFilePath);
        InputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
        OutputStream fileOutputStream = new FileOutputStream(outputFilePath);
        OutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
    ) {
        byte[] buffer = new byte[1024];
        int length;
        while((length = bufferedInputStream.read(buffer)) > 0) {
            bufferedOutputStream.write(buffer, 0, length);
        }
    }
}

可以看到代码可读性得到很大的提升,同时解决了上面提到的异常屏蔽的问题。我们看到上面使用的输入流输出流都进行了向上转型操作,转为其父类,这是遵循里氏替换原则,这个原则的核心是子类对象可以替换程序中父类对象出现的任何地方,并且保证程序的正确性。

音频格式转换器

上面也提到,录音器录制完音频可以选择输出两种数据,分别是PCM源数据和PCM文件,PCM是不能直接通过播放器播放的,因为它是音频的裸数据格式,想要播放的话,可以通过将音频转成数据流(也就是我们录音器输出的PCM源数据,当然也可以把PCM文件转成数据流),使用AudioTrack指定采样率、位深度和声道数等参数信息后进行播放,除此之外,还可以把PCM文件转换成WAV文件或者MP3文件,AudioFormatConverter就是用来处理这些逻辑。

PCM文件转换为WAV文件

WAV(Waveform Audio File Format)是微软专门为Windows开发的一种编码格式,它会在PCM数据格式的前面加上44字节,分别用来描述该PCM数据的采样率、声道数、量化格式。

WAV由若干个块(Chunk)组成,规范如下图所示:

wav_sound_format.gif

整理成表格,如下所示:

偏移地址字段大小字段名称字段描述字节序
0~34ChunkID字母“RIFF”大端
4~74ChunkSize总数据大小:36+Subchunk2Size(值为PCM文件大小,也就是totalAudioSize),更准确地说就是4 + (8 + Subchunk1Size(值为16)) + (8 + Subchunk2Size(值为PCM文件大小,也就是totalAudioSize))小端
8~114Format字母“WAVE“大端
12~154Subchunk1ID字符“fmt ”,要注意的是,最后是一位空格大端
16~194Subchunk1Size如果是PCM,值为16小端
20~212AudioFormat如果是PCM,值为1,表示线性量化小端
22~232NumChannels声道数小端
24~274SampleRate采样率小端
28~314ByteRate字节率:采样率 * 位深度 / 8 * 声道数小端
32~332BlockAlign每次采样的大小:声道数 * 位深度 / 8小端
34~352BitsPerSample每个采样的位数小端
36~394Subchunk2ID字母“data”大端
40~434Subchunk2Size音频数据的大小小端
44~……*Data音频数据小端

根据上面的描述,我们转换成代码,代码如下所示:

// AudioFormatConverter.kt
/**
 * 将PCM文件转换为WAV文件
 *
 * @param inputPCMFilePath 输入的PCM文件路径
 * @param outputWAVFilePath 输出的WAV文件路径
 * @param sampleRateInHz 采样率,单位:频率
 * @param bitDepth 位深度
 * @param channelCount 声道数
 * @return WAV文件
 */
@JvmStatic
suspend fun convertPCMToWAV(
    inputPCMFilePath: String,
    outputWAVFilePath: String,
    sampleRateInHz: Int,
    bitDepth: Int,
    channelCount: Int
): File? {
    if (inputPCMFilePath.isEmpty() || outputWAVFilePath.isEmpty()) {
        return null
    }
    return withIO {
        val outputWAVFile = File(outputWAVFilePath)
        if (!outputWAVFile.exists()) {
            outputWAVFile.parentFile?.mkdirs()
            outputWAVFile.createNewFile()
        }
        FileInputStream(inputPCMFilePath).use { fileInputStream ->
            BufferedInputStream(fileInputStream).use { bufferedInputStream ->
                FileOutputStream(outputWAVFilePath).use { fileOutputStream ->
                    BufferedOutputStream(fileOutputStream).use { bufferedOutputStream ->
                        val totalAudioSize = fileInputStream.channel.size()
                        // WAV文件头
                        writeWAVFileHeader(
                            bufferedOutputStream,
                            totalAudioSize,
                            sampleRateInHz,
                            bitDepth,
                            channelCount
                        )
                        // Data:音频数据
                        val buffer = ByteArray(1024)
                        var length: Int
                        while (bufferedInputStream.read(buffer).also { length = it } > 0) {
                            bufferedOutputStream.write(buffer, 0, length)
                        }
                    }
                }
            }
        }
        outputWAVFile
    }
}

/**
 * 把WAV文件头写入缓冲输出流
 *
 * @param bufferedOutputStream 缓冲输出流
 * @param totalAudioSize 整个音频PCM数据大小
 * @param sampleRateInHz 采样率,单位:频率
 * @param bitDepth 位深度
 * @param channelCount 声道数
 * @throws IOException IO异常
 */
@Throws(IOException::class)
private fun writeWAVFileHeader(
    bufferedOutputStream: BufferedOutputStream,
    totalAudioSize: Long,
    sampleRateInHz: Int,
    bitDepth: Int,
    channelCount: Int
) {
    val header: ByteArray = getWAVHeader(totalAudioSize, sampleRateInHz, bitDepth, channelCount)
    bufferedOutputStream.write(header, 0, 44)
}

/**
 * 获取WAV文件头
 *
 * @param totalAudioSize 音频数据的大小
 * @param sampleRateInHz 采样率,单位:频率
 * @param bitDepth 位深度
 * @param channelCount 声道数
 * @return 字节数组
 * @throws IOException IO异常
 */
@Throws(IOException::class)
private fun getWAVHeader(
    totalAudioSize: Long,
    sampleRateInHz: Int,
    bitDepth: Int,
    channelCount: Int
): ByteArray {
    val header = ByteArray(44)
    // ChunkID:字母“RIFF”
    header[0] = 'R'.code.toByte()
    header[1] = 'I'.code.toByte()
    header[2] = 'F'.code.toByte()
    header[3] = 'F'.code.toByte()
    /**
     * 总数据大小:36+Subchunk2Size(值为PCM文件大小,也就是totalAudioSize),更准确地说就是
     * 4 + (8 + Subchunk1Size(值为16)) + (8 + Subchunk2Size(值为PCM文件大小,也就是totalAudioSize))
     */
    val totalDataSize = 36 + totalAudioSize
    // ChunkSize:总数据大小
    header[4] = (totalDataSize and 0xff).toByte()
    header[5] = (totalDataSize shr 8 and 0xff).toByte()
    header[6] = (totalDataSize shr 16 and 0xff).toByte()
    header[7] = (totalDataSize shr 24 and 0xff).toByte()
    // Format:字母“WAVE”
    header[8] = 'W'.code.toByte()
    header[9] = 'A'.code.toByte()
    header[10] = 'V'.code.toByte()
    header[11] = 'E'.code.toByte()
    // Subchunk1ID:字符“fmt ”,要注意的是,最后是一位空格
    header[12] = 'f'.code.toByte()
    header[13] = 'm'.code.toByte()
    header[14] = 't'.code.toByte()
    header[15] = ' '.code.toByte()
    // Subchunk1Size:如果是PCM,值为16
    header[16] = 16
    header[17] = 0
    header[18] = 0
    header[19] = 0
    // AudioFormat:如果是PCM,值为1,表示线性量化
    header[20] = 1
    header[21] = 0
    // NumChannels:声道数
    header[22] = channelCount.toByte()
    header[23] = 0
    // SampleRate:采样率
    header[24] = (sampleRateInHz and 0xff).toByte()
    header[25] = (sampleRateInHz shr 8 and 0xff).toByte()
    header[26] = (sampleRateInHz shr 16 and 0xff).toByte()
    header[27] = (sampleRateInHz shr 24 and 0xff).toByte()
    // 字节率:采样率 * 位深度 / 8 * 声道数
    val byteRate: Long = (sampleRateInHz * bitDepth / 8 * channelCount).toLong()
    // ByteRate:字节率
    header[28] = (byteRate and 0xff).toByte()
    header[29] = (byteRate shr 8 and 0xff).toByte()
    header[30] = (byteRate shr 16 and 0xff).toByte()
    header[31] = (byteRate shr 24 and 0xff).toByte()
    // 每次采样的大小:声道数 * 位深度 / 8
    val blockAlign: Int = channelCount * bitDepth / 8
    // BlockAlign:每次采样的大小
    header[32] = blockAlign.toByte()
    header[33] = 0
    // BitsPerSample:每个采样的位数
    header[34] = 16
    header[35] = 0
    // Subchunk2ID:字母“data”
    header[36] = 'd'.code.toByte()
    header[37] = 'a'.code.toByte()
    header[38] = 't'.code.toByte()
    header[39] = 'a'.code.toByte()
    // Subchunk2Size:音频数据的大小
    header[40] = (totalAudioSize and 0xff).toByte()
    header[41] = (totalAudioSize shr 8 and 0xff).toByte()
    header[42] = (totalAudioSize shr 16 and 0xff).toByte()
    header[43] = (totalAudioSize shr 24 and 0xff).toByte()
    return header
}

题外话

  • 小端字节序:低位字节排在内存的低地址端高位字节排在内存的高地址端

  • 大端字节序:高位字节排在内存的低地址端低位字节排在内存的高地址端

假设有一个十六进制的整型数据0x01234567,要写入到地址为0x00001000~0x00001003中,它们的区别如下所示,其中第一行是地址,第二行是数据。

小端字节序如下所示:

0x000010000x0000100010x000010020x00001003
67452301

大端字节序如下所示:

0x000010000x0000100010x000010020x00001003
01234567

如果需要从最低位开始运算或者需要逐位运算,例如:检查奇偶性比较大小加法乘法或者更改数据类型,那么小端字节序是有优势;如果需要涉及到高位运算,例如:检查正负号,那么大端字节序是有优势。大端字节序比较符合大部分国家的阅读习惯(从左到右),所以它的可读性更好。

主机字节序是和CPU有关的,Intel和AMD这两个架构使用的是小端字节序。Java虚拟机(JVM)字节序通常和运行JVM的硬件架构有关,它会根据硬件架构自动转换,一般来说它是小端字节序。另外,由于TCP/IP协议(RFC 1700文档)规定使用大端字节序作为网络字节序,这意味着,当我们在网络上发送或者接收数据的时候,JVM会自动处理字节序的转换,以确保数据在源和目的之间正确进行序列化和反序列化。

使用LAME编码MP3文件

我们使用上一篇文章交叉编译出来的LAME库编码MP3文件,需要使用到LAME库大概三个功能:初始化编码销毁。我们使用上面新建的LameUtils类增加相关的函数,并且在mp3_encoder.cpp为这些函数生成JNI函数

初始化

要想用LAME库编码MP3文件,先要初始化LAME编码器,代码如下所示:

package com.tanjiajun.androidaudiodemo.utils

/**
 * Created by TanJiaJun on 2024/3/28.
 */
object LAMEUtils {

    init {
        System.loadLibrary("MP3Encoder")
    }

    // 省略部分代码

    /**
     * 初始化LAME
     *
     * @param inputPCMFilePath 输入的PCM文件路径
     * @param outputMP3FilePath 输出的MP3文件路径
     * @param sampleRateInHz 采样率,单位:赫兹
     * @param channelCount 声道数
     * @param bitRate 比特率
     * @return 是否初始化成功
     */
    external fun init(
        inputPCMFilePath: String,
        outputMP3FilePath: String,
        sampleRateInHz: Int,
        channelCount: Int,
        bitRate: Int
    ): Boolean

    // 省略部分代码

}

对应的C++代码如下所示:

// mp3_encoder.cpp
//
// Created by 谭嘉俊 on 2024/3/27.
//
#include <jni.h>
#include "lame/lame.h"

FILE *inputPCMFile = nullptr;
FILE *outputMP3File = nullptr;
lame_t lameClient = nullptr;

// 省略部分代码

extern "C"
JNIEXPORT jboolean JNICALL
Java_com_tanjiajun_androidaudiodemo_utils_LAMEUtils_init(
        JNIEnv *env,
        jobject thiz,
        jstring input_pcm_file_path,
        jstring output_mp3_file_path,
        jint sample_rate_in_hz,
        jint channel_count,
        jint bit_rate
) {
    // 将jstring类型的input_pcm_file_path转换为UTF-8编码的字符数组;因为不需要关心JVM是否会返回原始字符串的副本,所以isCopy参数传NULL
    const char *inputPCMFilePath = env->GetStringUTFChars(input_pcm_file_path, NULL);
    // 以读取二进制文件的方式打开需要输入的PCM文件,如果打开失败就返回NULL
    inputPCMFile = fopen(inputPCMFilePath, "rb");
    if (!inputPCMFile) {
        // 如果需要输入的PCM文件打开失败就返回false
        return false;
    }
    // 将jstring类型的output_mp3_file_path转换为UTF-8编码的字符数组;因为不需要关心JVM是否会返回原始字符串的副本,所以isCopy参数传NULL
    const char *outputMP3FilePath = env->GetStringUTFChars(output_mp3_file_path, NULL);
    // 以写入二进制文件的方式打开需要输出的MP3文件,如果打开失败就返回NULL
    outputMP3File = fopen(outputMP3FilePath, "wb");
    if (!outputMP3File) {
        // 如果需要输出的MP3文件打开失败就返回false
        return false;
    }
    // 初始化LAME编码器
    lameClient = lame_init();
    // 设置LAME编码器的输入采样率
    lame_set_in_samplerate(lameClient, sample_rate_in_hz);
    // 设置LAME编码器的输出采样率
    lame_set_out_samplerate(lameClient, sample_rate_in_hz);
    // 设置LAME编码器的量化格式
    lame_set_brate(lameClient, bit_rate / 1000);
    // 设置LAME编码器的声道数
    lame_set_num_channels(lameClient, channel_count);
    lame_init_params(lameClient);
    return true;
}

// 省略部分代码

在JNI中,Java字符串C/C++字符串之间的转换需要特别处理,因为Java字符串Unicode(统一码)的,它是为了解决传统的字符编码方案的局限而产生的,为每种语言中每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求;C/C++字符串通常是以字符形式存储的。从Java字符串转换到C/C++字符串,或者从C/C++字符串转换到Java字符串涉及到字符编码内存管理啊的问题,直接处理这些转换可能会导致字符串损坏、内存泄露等问题,所以JNI提供了一些工具函数来简化这个过程,例如:GetStringUTFChars函数和NewStringUTF函数,它们提供了自动化的字符串转换内存管理,确保了原生代码不会因为不当的处理而破坏Java字符串,并且保证了字符串的正确转换和释放。

我们看到该函数是以读取二进制文件的方式打开需要输入的PCM文件,以写入二进制文件的方式打开需要输出的MP3文件。fopen函数第二个参数是用来指定文件访问模式,它是个字符数组,有如下几种模式:

字符串说明
r只读模式,打开一个已存在的文件,并且文件必须存在,从文件的开头开始读。
r+读写模式,打开一个已存在的文件,并且文件必须存在,从文件的开头开始读写。
w写入模式,如果文件已存在,就把文件长度清为零,即文件内容会清空;如果文件不存在,就创建该文件。
a追加模式,如果文件已存在,就把写入的数据追加到文件尾后,也就是文件原先的内容会被保留,保留EOF符;如果文件不存在,就创建该文件。
a+追加模式,如果文件已存在,就把写入的数据追加到文件尾后,也就是文件原先的内容会被保留,不保留EOF符;如果文件不存在,就创建该文件。
x创建并写入,如果文件已存在,fopen函数返回NULL,并且失败错误代码会被设置为EEXIST
x+创建并读写,如果文件已存在,fopen函数返回NULL,并且失败错误代码会被设置为EEXIST

上面这些模式,除了x和x+,还可以添加b字符来指示以二进制模式打开文件,而不是文本模式,例如:rb、rb+、wb、ab和ab+。

编码

进入核心流程,使用LAME库编码MP3文件,代码如下所示:

package com.tanjiajun.androidaudiodemo.utils

/**
 * Created by TanJiaJun on 2024/3/28.
 */
object LAMEUtils {

    init {
        System.loadLibrary("MP3Encoder")
    }

    // 省略部分代码

    /**
     * 编码
     */
    external fun encode()

    // 省略部分代码

}

对应的C++代码如下所示:

//
// Created by 谭嘉俊 on 2024/3/27.
//
#include <jni.h>
#include "lame/lame.h"

FILE *inputPCMFile = nullptr;
FILE *outputMP3File = nullptr;
lame_t lameClient = nullptr;

// 省略部分代码

extern "C"
JNIEXPORT void JNICALL
Java_com_tanjiajun_androidaudiodemo_utils_LAMEUtils_encode(JNIEnv *env, jobject thiz) {
    if (!inputPCMFile || !outputMP3File || !lameClient) {
        return;
    }
    int bufferSize = 1024 * 256;
    short *buffer = new short[bufferSize / 2];
    short *leftBuffer = new short[bufferSize / 4];
    short *rightBuffer = new short[bufferSize / 4];
    unsigned char *mp3Buffer = new unsigned char[bufferSize];
    size_t readBufferSize;
    // 每次从PCM文件读取一段bufferSize大小的PCM数据buffer
    while ((readBufferSize = fread(buffer, 2, bufferSize / 2, inputPCMFile)) > 0) {
        for (int i = 0; i < readBufferSize; i++) {
            // 把该buffer的左右声道拆分开
            if (i % 2 == 0) {
                leftBuffer[i / 2] = buffer[i];
            } else {
                rightBuffer[i / 2] = buffer[i];
            }
        }
        // 编码左声道buffer和右声道buffer
        int wroteSize = lame_encode_buffer(
                lameClient,
                (short int *) leftBuffer,
                (short int *) rightBuffer,
                (int) (readBufferSize / 2),
                mp3Buffer,
                bufferSize
        );
        // 将编码后的数据写入MP3文件中
        fwrite(mp3Buffer, 1, wroteSize, outputMP3File);
    }
    // 释放内存,并且调用对象数组的析构函数
    delete[] buffer;
    delete[] leftBuffer;
    delete[] rightBuffer;
    delete[] mp3Buffer;
}

// 省略部分代码

核心逻辑就是代码里的一个循环,它每次从PCM文件读取一段bufferSize大小的PCM数据buffer,然后把该buffer的左右声道拆分开,通过lame_encode_buffer函数将左声道buffer右声道buffer送入到LAME编码器进行编码,最后将编码后的数据写入MP3文件中。

销毁

最后,记得要关闭先前打开的文件,把缓冲区内最后剩余的数据输出到内核缓冲区,并且释放文件指针和相关的缓冲区,同时销毁LAME编码器,代码如下所示:

package com.tanjiajun.androidaudiodemo.utils

/**
 * Created by TanJiaJun on 2024/3/28.
 */
object LAMEUtils {

    init {
        System.loadLibrary("MP3Encoder")
    }

    // 省略部分代码

    /**
     * 销毁
     */
    external fun destroy()

}

对应的C++代码如下所示:

//
// Created by 谭嘉俊 on 2024/3/27.
//
#include <jni.h>
#include "lame/lame.h"

FILE *inputPCMFile = nullptr;
FILE *outputMP3File = nullptr;
lame_t lameClient = nullptr;

// 省略部分代码

extern "C"
JNIEXPORT void JNICALL
Java_com_tanjiajun_androidaudiodemo_utils_LAMEUtils_destroy(JNIEnv *env, jobject thiz) {
    if (!inputPCMFile) {
        return;
    }
    // 关闭先前打开的PCM文件,把缓冲区内最后剩余的数据输出到内核缓冲区,并且释放文件指针和相关的缓冲区
    fclose(inputPCMFile);
    if (!outputMP3File) {
        return;
    }
    // 关闭先前打开的MP3文件,把缓冲区内最后剩余的数据输出到内核缓冲区,并且释放文件指针和相关的缓冲区
    fclose(outputMP3File);
    if (!lameClient) {
        return;
    }
    // 销毁LAME编码器
    lame_close(lameClient);
}

播放器

该播放器分为两种模式:AudioPCMPlayerAudioPlayer

Android的SDK(指的是Java层提供的API)提供了三套音频播放的API:AudioTrackMediaPlayerSoundPool

  • AudioTrack:它是最底层的音频播放API,只允许输入裸数据,适合低延迟的播放,提供了非常强大的控制能力,适合流媒体的播放等场景。由于它是最底层的API,所以需要结合解码器来使用。

  • MediaPlayer:适合在后台长时间播放本地音乐文件或者在线的流式媒体文件,它的封装层次比较高,使用起来比较简单。

  • SoundPool:适合播放比较短的音频,或者需要重复播放的音频,例如:游戏声音、按键声音或者铃声等等,它可以同时播放多个音频。它的底层是通过OpenSL ES来实现的,通过JNI与底层的MediaPlayer进行交互的,本质上还是使用MediaPlayer来解码并且播放,只是它会将音频数据缓存在内存中,同时为每一个音频生成对应一个索引号,以后每次播放的时候就根据索引号找到内存中对应的音频进行解码播放。它用到了池化技术,提高了资源的利用率,池化技术有这四个优点:节约资源优化响应时间更好地控制资源的数量更好地预测系统的性能

SoundPool相对于MediaPlayer来说,大大提高响应性同时减少了CPU计算开销,这种策略属于用空间换时间,凡事有两面性,当然这也是有相对应的缺点,那就是如果是很长的音频,那么产生的缓存就会很大,占用的资源就很多,所以它适合比较短的音频。

除此之外,还可以使用ExoPlayer播放,它是Jetpack Media3中的Player接口的默认实现,和MediaPlayer的API相比,它增加了额外的便利性,例如:支持多种流式传输协议默认音频和视频渲染程序以及处理媒体缓冲的组件。

Android的NDK提供了OpenSL ES的C语言的接口,可以提供非常强大的音效处理低延迟播放等功能,例如:在Android手机上实现实时耳返的功能。

AudioPCMPlayer

AudioPCMPlayer使用AudioTrack指定采样率、位深度和声道数等参数信息后播放数据流(也就是我们录音器输出的PCM源数据)。总体设计也是用到了建造者模式,这里只列出播放位深度为16bit的PCM音频的核心代码,代码如下所示:

// AudioPCMPlayer.kt
/**
 * 播放位深度为16bit的PCM音频
 *
 * @param audioData 音频数据
 * @param listener 音频播放监听器
 */
suspend fun play16BitPCMAudio(
    audioData: List<ShortArray>,
    listener: AudioPCMPlayListener? = null
) {
    if (audioTrack.state == AudioTrack.STATE_UNINITIALIZED) {
        return
    }
    if (audioFormat != AudioPCMPlayerFormat.PCM_16BIT) {
        return
    }
    withIO {
        audioTrack.play()
        audioData.forEach {
            if (audioTrack.state == AudioTrack.STATE_UNINITIALIZED) {
                return@forEach
            }
            if (audioTrack.playState != AudioTrack.PLAYSTATE_PLAYING) {
                return@forEach
            }
            audioTrack.write(it, 0, it.size)
        }
        withMain {
            listener?.onCompletion()
        }
    }
}

AudioTrack的工作流程大概如下所示:

  1. 根据音频配置信息(例如:采样率、位深度和声道数等等)创建一个AudioTrack对象。

  2. 调用AudioTrack的play函数,将AudioTrack切换到播放状态。

  3. 启动IO线程,循环向AudioTrack的缓冲区中写入音频数据。

  4. 音频数据写完或者停止播放的时候,停止对应的IO线程,并且释放所有资源。

要注意的是,在创建AudioTrack的时候,有个TransferMode(传输模式)需要设置,它有两个模式分别是:MODE_STATICMODE_STREAM,MODE_STATIC需要一次性将所有的数据写入播放缓冲区中,通常用于播放比较短的音频,例如:铃声系统提醒声;MODE_STREAM需要按照一定的时间间隔不间断地写入音频数据,可以应用于任何音频播放的场景,我们的播放器就是使用它。创建AudioTrack对象的代码如下所示:

// AudioPCMPlayer.kt
fun build(): AudioPCMPlayer {
    minBufferSize =
        AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig.value, audioFormat.value)
    audioTrack = AudioTrack.Builder()
        .setBufferSizeInBytes(minBufferSize)
        .setAudioFormat(
            AudioFormat.Builder()
                .setSampleRate(sampleRateInHz)
                .setEncoding(audioFormat.value)
                .setChannelMask(channelConfig.value)
                .build()
        )
        .setAudioAttributes(
            AudioAttributes.Builder()
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .build()
        )
        .setTransferMode(AudioTrack.MODE_STREAM)
        .build()
    return AudioPCMPlayer(
        audioTrack,
        audioFormat
    )
}

AudioPlayer

AudioPlayer通过MediaPlayer播放WAV、MP3等格式的音频,播放的核心代码如下所示:

// AudioPlayer.kt
/**
 * 播放音频
 *
 * @param audioFilePath 音频文件路径
 * @param listener 音频播放监听器
 */
suspend fun play(audioFilePath: String, listener: AudioPlayer.AudioPlayerListener? = null) {
    if (audioFilePath.isEmpty()) {
        return
    }
    withIO {
        mediaPlayer.reset()
        withMain {
            mediaPlayer.setOnCompletionListener {
                listener?.onCompletion()
            }
        }
        mediaPlayer.setDataSource(audioFilePath)
        mediaPlayer.prepare()
        mediaPlayer.start()
    }
}

UI界面

进入音频编辑页(AudioEditingActivity)前需要请求运行时权限,需要以下权限:

  • Android版本大于等于13(API Level >= 33)需要READ_MEDIA_AUDIO(读取媒体音频),反之需要READ_EXTERNAL_STORAGE(读取外部存储)和WRITE_EXTERNAL_STORAGE(写入外部存储)。

  • RECORD_AUDIO(录制音频)。

使用AndroidX库中RequestPermission相关的API,核心代码如下所示:

// MainActivity.kt
private val requestPermissionsLauncher: ActivityResultLauncher<Array<String>> =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grantResults: Map<String, Boolean> ->
        when {
            grantResults[Manifest.permission.READ_EXTERNAL_STORAGE] == false ->
                toastShort(getString(R.string.need_read_external_storage_permission))

            grantResults[Manifest.permission.WRITE_EXTERNAL_STORAGE] == false ->
                toastShort(getString(R.string.need_write_external_storage_permission))

            grantResults[Manifest.permission.READ_MEDIA_AUDIO] == false ->
                toastShort(getString(R.string.need_read_media_audio_permission))

            grantResults[Manifest.permission.RECORD_AUDIO] == false ->
                toastShort(getString(R.string.need_record_audio_permission))

            else ->
                navigateToAudioEditingPage()
        }
    }

确认所有权限已经获得后,就可以进入音频编辑页,该页面大概的流程:使用录音,录完音后可以直接播放音频试听,还可以把音频数据保存为PCM文件,同时显示PCM文件路径,然后可以转换为WAV文件或者MP3文件播放,同样的,也会显示对应文件的路径了;可以随时暂停或者继续录音,在不退出该页面的情况下,可以在上次的录音数据后面继续录音;在保存和转换音频过程中,因为是耗时操作,所以会显示相关的Loading视图。核心代码如下所示:

// AudioEditingActivity.kt
@Composable
private fun ContentView() {
    viewModel = viewModel(factory = AudioEditingViewModel.provideFactory())
    with(viewModel) {
        setSavingText(getString(R.string.saving))
        setConvertingText(getString(R.string.converting))
        setEncodingText(getString(R.string.encoding))
    }
    Column(
        modifier = Modifier.padding(
            start = 16.dp,
            top = 10.dp,
            end = 16.dp
        )
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically
        ) {
            AudioRecordButton()
            Spacer10dp()
            AudioRecordingDurationText()
        }
        Spacer10dp()
        AudioPlayButton()
        Spacer10dp()
        SaveAsPCMFileButton()
        Spacer5dp()
        AudioPCMFileAbsolutePathText()
        Spacer10dp()
        Row {
            ConvertToWAVFileButton()
            PlayWAVFileButton()
        }
        Spacer5dp()
        AudioWAVFileAbsolutePathText()
        Spacer10dp()
        Row {
            EncodeToMP3FileButton()
            PlayMP3FileButton()
        }
        Spacer5dp()
        AudioMP3FileAbsolutePathText()
    }
    LoadingView()
}

我的GitHub:TanJiaJunBeyond

Android通用框架:Android通用框架

我的掘金:谭嘉俊

我的简书:谭嘉俊

我的CSDN:谭嘉俊

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/589488.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Golang | Leetcode Golang题解之第64题最小路径和

题目&#xff1a; 题解&#xff1a; func minPathSum(grid [][]int) int {if len(grid) 0 || len(grid[0]) 0 {return 0}rows, columns : len(grid), len(grid[0])dp : make([][]int, rows)for i : 0; i < len(dp); i {dp[i] make([]int, columns)}dp[0][0] grid[0][0]…

服务器IP选择

可以去https://ip.ping0.cc/查看IP的具体情况 1.IP位置--如果是国内用&#xff0c;国外服务器的话建议选择日本&#xff0c;香港这些比较好&#xff0c;因为它们离这里近&#xff0c;一般延时低&#xff08;在没有绕一圈的情况下&#xff09;。 不过GPT的话屏蔽了香港IP 2. 企…

Mac 安装John the Ripper 破解rar(zip)压缩文件

注&#xff1a;仅以此篇记录我满足好奇心所逝去的十几个小时。&#xff08;自娱自乐&#xff09; 1、首先利用 brewhome 包管理工具 安装john the ripper &#xff1a; brew install john-jumbo 如果没有安装brewhome 利用如下命令安装&#xff1a; /bin/zsh -c "$(c…

LeetCode-网络延迟时间(Dijkstra算法)

每日一题 今天刷到一道有关的图的题&#xff0c;需要求单源最短路径&#xff0c;因此使用Dijkstra算法。 题目要求 有 n 个网络节点&#xff0c;标记为 1 到 n。 给你一个列表 times&#xff0c;表示信号经过 有向 边的传递时间。 times[i] (ui, vi, wi)&#xff0c;其中 …

【跟马少平老师学AI】-【神经网络是怎么实现的】(七-1)词向量

一句话归纳&#xff1a; 1&#xff09;神经网络不仅可以处理图像&#xff0c;还可以处理文本。 2&#xff09;神经网络处理文本&#xff0c;先要解决文本的表示&#xff08;图像的表示用像素RGB&#xff09;。 3&#xff09;独热编码词向量&#xff1a; 词表&#xff1a;{我&am…

OpenVINO安装教程 Docker版

从 Docker 映像安装IntelDistribution OpenVINO™ 工具套件 本指南介绍了如何使用预构建的 Docker 镜像/手动创建镜像来安装 OpenVINO™ Runtime。 Docker Base 映像支持的主机操作系统&#xff1a; Linux操作系统 Windows (WSL2) macOS(仅限 CPU exectuion) 您可以使用预…

【跟马少平老师学AI】-【神经网络是怎么实现的】(八)循环神经网络

一句话归纳&#xff1a; 1&#xff09;词向量与句子向量的循环神经网络&#xff1a; x(i)为词向量。h(i)为含前i个词信息的向量。h(t)为句向量。 2&#xff09;循环神经网络的局部。 每个子网络都是标准的全连接神经网络。 3&#xff09;对句向量增加全连接层和激活函数。 每个…

I2C接口18路LED呼吸灯驱动IS31FL3218互相替代SN3218替换HTR3218

I2C接口18路LED呼吸灯控制电路IC 该型号IC为QFN24接口&#xff0c;属于小众产品&#xff0c;IS31FL3218、SN3218、HTR3218S管脚兼容&#xff0c;需要注意的是HTR3218管脚与其他型号不兼容。 I2C接口可实现多个LED灯的呼吸灯控制&#xff0c;可实现单色控制18个LED灯&#xff0…

【ARM Cache 系列文章 11.2 -- ARM Cache 组相联映射】

请阅读【ARM Cache 系列文章专栏导读】 文章目录 Cache 组相联映射组相联映射原理多路组相连缓存的优势多路组相连缓存的代价关联度&#xff08;Associativity&#xff09; 上篇文章&#xff1a;【ARM Cache 系列文章 11.1 – ARM Cache 全相连 详细介绍】 Cache 组相联映射 A…

笔记1--Llama 3 超级课堂 | Llama3概述与演进历程

1、Llama 3概述 https://github.com/SmartFlowAI/Llama3-Tutorial.git 【Llama 3 五一超级课堂 | Llama3概述与演进历程】 2、Llama 3 改进点 【最新【大模型微调】大模型llama3技术全面解析 大模型应用部署 据说llama3不满足scaling law&#xff1f;】…

Deep learning Part Five RNN--24.4.29

接着上期&#xff0c;CBOW模型无法解决文章内容过长的单词预测的&#xff0c;那该如何解决呢&#xff1f; 除此之外&#xff0c;根据图中5-5的左图所示&#xff0c;在CBOW模型的中间层求单词向量的和&#xff0c;这时就会出现另一个问题的&#xff0c;那就是上下文的单词的顺序…

Redis Zset的底层原理

Redis Zset的底层原理 ZSet也就是SortedSet&#xff0c;其中每一个元素都需要指定一个score值和member值&#xff1a; 可以根据score值排序后member必须唯一可以根据member查询分数 因此&#xff0c;zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学…

ZooKeeper知识点总结及分布式锁实现

最初接触ZooKeeper是之前的一个公司的微服务项目中&#xff0c;涉及到Dubbo和ZooKeeper&#xff0c;ZooKeeper作为微服务的注册和配置中心。好了&#xff0c;开始介绍ZooKeeper了。 目录 1.ZooKeeper的基本概念 2.ZooKeeper的节点&#xff08;ZNode&#xff09; 3. ZooKeep…

【Java笔记】第5章:函数

前言1. 函数的理解2. 函数的基本使用3. 函数的参数4. 函数的返回值5. 函数的执行机制6. 函数的递归调用结语 ↓ 上期回顾: 【Java笔记】第4章&#xff1a;深入学习循环结构 个人主页&#xff1a;C_GUIQU 归属专栏&#xff1a;【Java学习】 ↑ 前言 各位小伙伴大家好&#xff…

[随记]Mac安装Docker及运行开源Penpot

下载Docker Desktop for Mac&#xff1a;https://www.docker.com/products/docker-desktop/ 安装Docker Desktop for Mac&#xff0c;安装完成后&#xff0c;启动Docker&#xff0c;然后在终端输入&#xff1a; docker version 在Mac电脑的Desktop&#xff0c;随便创建一个文…

【真实体验】使用崖山YMP 迁移 Oracle/MySQL 至YashanDB 23.2 验证测试【YashanDB迁移体验官】

一、前言 说一下我和崖山数据库的结缘&#xff0c;大概在去年吧&#xff0c;因为我经常在墨天轮写文章&#xff0c;看到崖山数据库推出了一崖山体验官的活动&#xff0c;我就报名参加了。第一次体验了崖山数据库&#xff0c;也测试了我司数据库到崖山数据库的兼容性&#xff0…

钉钉手机端调试前端H5项目流程

此流程以Vue项目为例 一、操作步骤 在根目录下 vue.config.js 文件中将 devServer.host 设置为 0.0.0.0 // vue.config.js module.exports {devServer: {host: 0.0.0.0,...},...}本地启动项目&#xff0c;获取 Network App running at:- Local: http://localhost:8080/ -…

JAVA 学习·泛型(二)——通配泛型

有关泛型的基本概念&#xff0c;参见我的前一篇博客 JAVA 学习泛型&#xff08;一&#xff09;。 协变性 泛型不具备协变性 在介绍通配泛型之前&#xff0c;先来看一下下面的例子。我们定义了一个泛型栈&#xff1a; import java.util.ArrayList; class GenericStack<E>…

全新TOF感知RGBD相机 | 高帧率+AI,探索3D感知新境界

海康机器人在近期的机器视觉新品发布会上推出的全新TOF感知RGBD相机,无疑是对当前机器视觉技术的一次革新。这款相机不仅融合了高帧率、轻松集成、体积小巧以及供电稳定等诸多优点,更重要的是,它将AI与3D感知技术完美结合,通过高帧率+AI算法,实现了对不同场景的快速捕捉与…

Android Studio报错:Constant expression required

【出现的问题】&#xff1a; 使用JDK17以上版本&#xff0c;switch语句报错&#xff1a;Constant expression required 【解决方法】&#xff1a; 在gradle.properties配置文件下添加代码&#xff1a; android.nonFinalResIdsfalse 如图&#xff1a; 接着再点击右上角的Sync…