音视频开发之旅——实现录音器、音频格式转换器和播放器(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(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:这是个可选参数,有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 {@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)组成,规范如下图所示:

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: 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中,它们的区别如下所示,其中第一行是地址,第二行是数据。

小端字节序如下所示:

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参数传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);
}

播放器

该播放器分为两种模式: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:谭嘉俊

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Leetcode—163. 缺失的区间【简单】Plus

2024每日刷题&#xff08;126&#xff09; Leetcode—163. 缺失的区间 实现代码 class Solution { public:vector<vector<int>> findMissingRanges(vector<int>& nums, int lower, int upper) {int n nums.size();vector<vector<int>> an…

docker部署nginx并配置https

1.准备SSL证书&#xff1a; 生成私钥&#xff1a;运行以下命令生成一个私钥文件。 生成证书请求&#xff08;CSR&#xff09;&#xff1a;运行以下命令生成证书请求文件。 生成自签名证书&#xff1a;使用以下命令生成自签名证书。 openssl genrsa -out example.com.key 2048 …

目标检测——铁路轨道故障数据集

一、重要性及意义 安全性保障&#xff1a;铁路作为重要的交通工具&#xff0c;其安全性能直接关系到乘客和货物的安全。铁路轨道故障&#xff0c;如裂缝、变形、错位、缺失紧固件等&#xff0c;都可能引发列车脱轨、倾覆等严重事故。因此&#xff0c;及时发现和修复这些故障&a…

【LLM第二篇】stable diffusion扩散模型、名词解释

最近在整理大模型的相关资料&#xff0c;发现了几个名词&#xff0c;不是很懂&#xff0c;这里整理一下&#xff1a; stable diffusion&#xff08;SD)模型&#xff1a; 扩散模型&#xff08;Diffusion model&#xff09;的一种&#xff0c;主要用于生成高质量的图像&#xf…

论文阅读笔记(AAAI 20)Order Matters

个人博客地址 注&#xff1a;部分内容参考自GPT生成的内容 论文笔记&#xff1a;Order Matters&#xff08;AAAI 20&#xff09; 用于二进制代码相似性检测的语义感知神经网络 论文:《Order Matters: Semantic-Aware Neural Networks for Binary Code Similarity Detection》…

MongoDB的分片集群

MongoDB分片技术 介绍 ​ 分片&#xff08;sharding&#xff09;是MongoDB用来将大型集合分割到不同服务器上采用的方法。分片这种说法起源于关系型数据库。但是实际上非关系型数据库在分片方面相比于传统的关系型数据库更有优势。 ​ 与MySQL分库方案对比&#xff0c;MongoDB…

Hibernate的QBC与HQL查询

目录 1、Hibernate的QBC查询 2、Hibernate的HQL查询 3、NatvieSQL原生查询 1、Hibernate的QBC查询 Hibernate具有一个直观的、可扩展的条件查询API public class Test { /** * param args */ public static void main(String[] args) { Session sessio…

Deep Learning Part Eight--Attention 24.5.4

01.在翻译、语音识别等将一个时序数据转换为另一个时序数据的任务中&#xff0c;时序数据之间常常存在对应关系 引入了Attention的概念&#xff0c;介绍了Attention的注意力机制&#xff1a; 困难出现&#xff0c;seq2seq的问题引入&#xff1a;固定化长度问题&#xff08;过于…

【数学建模】矩阵微分方程

一、说明 我相信你们中的许多人都熟悉微分方程&#xff0c;或者至少知道它们。微分方程是数学中最重要的概念之一&#xff0c;也许最著名的微分方程是布莱克-斯科尔斯方程&#xff0c;它控制着任何股票价格。 ​​ 股票价格的布莱克-斯科尔斯模型 微分方程可以由数学中的许多…

java基于云计算的SaaS医院his信息系统源码 HIS云平台源码

目录 云HIS功能模块 1、预约挂号&#xff1a; 2、药库管理&#xff1a; 3、门诊医生站&#xff1a; 4、门诊费用&#xff1a; 5、药房管理&#xff1a; 6、治疗室&#xff08;门诊护士工作站&#xff09;&#xff1a; 7、统计分析&#xff1a; 8、财务管理&#xff1a;…

香蕉新鲜度等级识别香蕉成熟度识别分类数据集13478张4类别

数据集类型&#xff1a;图像分类用&#xff0c;不可用于目标检测无标注文件 数据集格式&#xff1a;仅仅包含jpg图片&#xff0c;每个类别文件夹下面存放着对应图片 图片数量(jpg文件个数)&#xff1a;13478 分类类别数&#xff1a;4 类别名称:["overripe","rip…

STM32G474 CMAKE VSCODE FREERTOS 导入

一. 文件准备 1. 首先下载 freertos FreeRTOS - Free RTOS Source Code Downloads, the official FreeRTOS zip file release download 2. 移动 FreeRTOS-Kenel 到 moto_control 文件夹下。 3. 将 FreeRTOSConfig.h 放到 /Core/Inc 下面 4. 由于 FreeRTOSConfig.h 中使用了…

腾讯云CentOS7使用Docker安装ElasticSearch与Kibana详细教程

文章目录 一、安装ElasticSearch二、安装Kibana 一、安装ElasticSearch 使用Docker拉取ElasticSearch镜像 这里版本选择的是7.15.2 docker pull docker.elastic.co/elasticsearch/elasticsearch:7.15.22. 查看ElasticSearch的镜像id docker images3. 创建ElasticSearch容器 …

鸿蒙开发仿咸鱼TabBar

鸿蒙开发自定义TabBar&#xff0c;实现tabBar 上中间按钮凸起效果 第一步、定义数据模型 export default class TabItemData{defaultIcon: ResourceselectedIcon: Resourcetitle: stringisMiddle: booleanconstructor(defaultIcon:Resource, selectedIcon:Resource, title:st…

【C++】文件

目录 文件文件分类文本文件的读写(ASCII文件)的读写打开文件打开文件的方式关闭文件将数据写入ASCII文件从ASCII文件读入数据 二进制存储对比ASCII和二进制存储用成员函数read和write读写二进制文件打开方式文件的读入与读出 文件 所谓文件&#xff0c;一般指存储在外部介质上…

c#学习基础1

一、复杂数据类型 1&#xff09;概述 2&#xff09;枚举 1.基本概念 枚举是一个比较特别的存在&#xff0c;它是一个被命名的整形常量的集合&#xff0c;一般用它来表示状态&#xff0c;类型等 1.1申明枚举和申明枚举变量 1.2申明枚举语法 2.在哪里申明枚举 3.枚举的使用 4…

Java 获取 Outlook 邮箱的日历事件

Java 获取 Outlook 邮箱的日历事件 1.需求描述2.实现方案3.运行结果 IDE&#xff1a;IntelliJ IDEA 2022.3.3 JDK&#xff1a;1.8.0_351 Outlook&#xff1a;Microsoft Office 2016 1.需求描述 比如现在需要获取 Outlook 邮箱中四月的全部的会议安排&#xff0c;如下图所示 …

anaconda、cuda、tensorflow、pycharm环境安装

anaconda、cuda、tensorflow、pycharm环境安装 anaconda安装 anaconda官方下载地址 本文使用的是基于python3.9的anaconda 接下来跟着步骤安装&#xff1a; 检验conda是否成功安装 安装CUDA和cuDNN 提醒&#xff0c;CUDA和cuDNN两者必须版本对应&#xff0c;否者将会出错…

Educational Codeforces Round 165 (Rated for Div. 2) (C、D)

1969C - Minimizing the Sum 题意&#xff1a; 思路&#xff1a;观察到操作数很小&#xff0c;最值问题操作数很容易想到dp&#xff0c;用表示第个元素&#xff0c;操作了次的最小值总和&#xff0c;转移的时候枚举连续操作了几次即可&#xff0c;而连续操作了几次即将全部变成…

陈随易:论技术思维和产品思维

大家好&#xff0c;我是不被定义的前端之虎陈随易。 我的个人网站是&#xff1a;https://chensuiyi.me&#xff0c;欢迎大家眼熟我。 写这篇文章呢&#xff0c;源于一次群聊。 群友有一个产品&#xff0c;其中涉及到免费用户和付费用户对 pdf 的查看权限问题&#xff0c;使用…