本文主要讲解的是实现录音器、音频转换器和播放器,在实现过程中需要把PCM文件转换为WAV文件,同时需要使用上一篇文章交叉编译出来的LAME库编码MP3文件。本文基于Android平台,示例代码如下所示:
AndroidAudioDemo
Android系列:
音视频开发之旅——音频基础概念、交叉编译原理和实践(LAME的交叉编译)(Android)
iOS系列:
音视频开发之旅——音频基础概念、交叉编译原理和实践(LAME的交叉编译)(iOS)
项目主要分为三个部分:录音器、音频格式转换器和播放器。
准备工作
需求需要在上一篇文章的示例代码上去实现,为了代码规范,我们先重构下之前的代码,主要是以下三个过程:
-
重命名androidaudiodemo.cpp。
-
CMakeLists.txt修改动态库的名称。
-
调整相关的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(MP3EncoderSHAREDmp3_encoder.cpplame/reservoir.clame/mpglib_interface.clame/machine.hlame/fft.hlame/set_get.clame/quantize_pvt.hlame/psymodel.hlame/newmdct.clame/id3tag.hlame/lame-analysis.hlame/id3tag.clame/reservoir.hlame/lameerror.hlame/set_get.hlame/quantize.clame/fft.clame/l3side.hlame/newmdct.hlame/quantize.hlame/gain_analysis.clame/encoder.clame/lame.clame/bitstream.clame/quantize_pvt.clame/presets.clame/bitstream.hlame/encoder.hlame/gain_analysis.hlame/lame_global_flags.hlame/psymodel.clame/lame.hlame/tables.clame/tables.hlame/takehiro.clame/util.clame/util.hlame/vbrquantize.clame/vbrquantize.hlame/VbrTag.clame/VbrTag.hlame/version.clame/version.h
)target_link_libraries(MP3Encoderandroidlog
)
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:这是个可选参数,有STATIC、SHARED和MODULE三个类型,如果未给定这个参数,就会根据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 {@NotNullpublic static final LAMEUtils INSTANCE;@NotNullpublic 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.utilsimport 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: Intif (audioFormat == AudioRecorderFormat.PCM_16BIT) {val shortArray = ShortArray(minBufferSize)byteCount = audioRecord.read(shortArray, 0, shortArray.size) * 2withMain {shortArrays.add(shortArray)}} else {val floatArray = FloatArray(minBufferSize)byteCount = audioRecord.read(floatArray,0,floatArray.size,AudioRecord.READ_BLOCKING) * 4withMain {floatArrays.add(floatArray)}}if (byteCount <= 0) {return@withIO}withMain {byteLength += byteCountval 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 = 0LaudioRecord.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 = 0private lateinit var audioRecord: AudioRecordprivate var audioSource: AudioRecorderSource = AudioRecorderSource.MICprivate var sampleRateInHz: Int = 44100private var audioFormat: AudioRecorderFormat = AudioRecorderFormat.PCM_16BITprivate var channelConfig: AudioRecorderChannelConfig = AudioRecorderChannelConfig.STEREOprivate var addAcousticEchoCanceler: Boolean = falseprivate var addAutomaticGainControl: Boolean = falseprivate var addNoiseSuppressor: Boolean = falseprivate var listener: AudioRecordListener? = nullprivate var acousticEchoCanceler: AcousticEchoCanceler? = nullprivate var automaticGainControl: AutomaticGainControl? = nullprivate var noiseSuppressor: NoiseSuppressor? = null/*** 设置音频来源*/fun setAudioSource(audioSource: AudioRecorderSource): Builder {this.audioSource = audioSourcereturn this}/*** 设置采样率*/fun setSampleRateInHz(sampleRateInHz: Int): Builder {this.sampleRateInHz = sampleRateInHzreturn this}/*** 设置音频格式*/fun setAudioFormat(audioFormat: AudioRecorderFormat): Builder {this.audioFormat = audioFormatreturn this}/*** 设置声道配置*/fun setChannelConfig(channelConfig: AudioRecorderChannelConfig): Builder {this.channelConfig = channelConfigreturn this}/*** 添加声学回声消除器*/fun addAcousticEchoCanceler(): Builder {addAcousticEchoCanceler = truereturn this}/*** 添加自动增益控制*/fun addAutomaticGainControl(): Builder {addAutomaticGainControl = truereturn this}/*** 添加噪音抑制器*/fun addNoiseSuppressor(): Builder {addNoiseSuppressor = truereturn this}/*** 设置录音监听者*/fun setAudioRecordListener(listener: AudioRecordListener): Builder {this.listener = listenerreturn 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 * channelCountreturn 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? = nulltry {return block(this)} catch (e: Throwable) {exception = ethrow 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)组成,规范如下图所示:
整理成表格,如下所示:
偏移地址 | 字段大小 | 字段名称 | 字段描述 | 字节序 |
---|---|---|---|---|
0~3 | 4 | ChunkID | 字母“RIFF” | 大端 |
4~7 | 4 | ChunkSize | 总数据大小:36+Subchunk2Size(值为PCM文件大小,也就是totalAudioSize),更准确地说就是4 + (8 + Subchunk1Size(值为16)) + (8 + Subchunk2Size(值为PCM文件大小,也就是totalAudioSize)) | 小端 |
8~11 | 4 | Format | 字母“WAVE“ | 大端 |
12~15 | 4 | Subchunk1ID | 字符“fmt ”,要注意的是,最后是一位空格 | 大端 |
16~19 | 4 | Subchunk1Size | 如果是PCM,值为16 | 小端 |
20~21 | 2 | AudioFormat | 如果是PCM,值为1,表示线性量化 | 小端 |
22~23 | 2 | NumChannels | 声道数 | 小端 |
24~27 | 4 | SampleRate | 采样率 | 小端 |
28~31 | 4 | ByteRate | 字节率:采样率 * 位深度 / 8 * 声道数 | 小端 |
32~33 | 2 | BlockAlign | 每次采样的大小:声道数 * 位深度 / 8 | 小端 |
34~35 | 2 | BitsPerSample | 每个采样的位数 | 小端 |
36~39 | 4 | Subchunk2ID | 字母“data” | 大端 |
40~43 | 4 | Subchunk2Size | 音频数据的大小 | 小端 |
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: Intwhile (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,值为16header[16] = 16header[17] = 0header[18] = 0header[19] = 0// AudioFormat:如果是PCM,值为1,表示线性量化header[20] = 1header[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()// 每次采样的大小:声道数 * 位深度 / 8val blockAlign: Int = channelCount * bitDepth / 8// BlockAlign:每次采样的大小header[32] = blockAlign.toByte()header[33] = 0// BitsPerSample:每个采样的位数header[34] = 16header[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中,它们的区别如下所示,其中第一行是地址,第二行是数据。
小端字节序如下所示:
0x00001000 | 0x000010001 | 0x00001002 | 0x00001003 |
---|---|---|---|
67 | 45 | 23 | 01 |
大端字节序如下所示:
0x00001000 | 0x000010001 | 0x00001002 | 0x00001003 |
---|---|---|---|
01 | 23 | 45 | 67 |
如果需要从最低位开始运算或者需要逐位运算,例如:检查奇偶性、比较大小、加法、乘法或者更改数据类型,那么小端字节序是有优势;如果需要涉及到高位运算,例如:检查正负号,那么大端字节序是有优势。大端字节序比较符合大部分国家的阅读习惯(从左到右),所以它的可读性更好。
主机字节序是和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参数传NULLconst char *inputPCMFilePath = env->GetStringUTFChars(input_pcm_file_path, NULL);// 以读取二进制文件的方式打开需要输入的PCM文件,如果打开失败就返回NULLinputPCMFile = fopen(inputPCMFilePath, "rb");if (!inputPCMFile) {// 如果需要输入的PCM文件打开失败就返回falsereturn false;}// 将jstring类型的output_mp3_file_path转换为UTF-8编码的字符数组;因为不需要关心JVM是否会返回原始字符串的副本,所以isCopy参数传NULLconst char *outputMP3FilePath = env->GetStringUTFChars(output_mp3_file_path, NULL);// 以写入二进制文件的方式打开需要输出的MP3文件,如果打开失败就返回NULLoutputMP3File = fopen(outputMP3FilePath, "wb");if (!outputMP3File) {// 如果需要输出的MP3文件打开失败就返回falsereturn 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数据bufferwhile ((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和右声道bufferint 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);
}
播放器
该播放器分为两种模式:AudioPCMPlayer和AudioPlayer。
Android的SDK(指的是Java层提供的API)提供了三套音频播放的API:AudioTrack、MediaPlayer和SoundPool。
-
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的工作流程大概如下所示:
-
根据音频配置信息(例如:采样率、位深度和声道数等等)创建一个AudioTrack对象。
-
调用AudioTrack的play函数,将AudioTrack切换到播放状态。
-
启动IO线程,循环向AudioTrack的缓冲区中写入音频数据。
-
当音频数据写完或者停止播放的时候,停止对应的IO线程,并且释放所有资源。
要注意的是,在创建AudioTrack的时候,有个TransferMode(传输模式)需要设置,它有两个模式分别是:MODE_STATIC和MODE_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:谭嘉俊