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

news2024/11/17 5:31:54

本文主要讲解的是实现录音器音频转换器播放器,在实现过程中需要把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:谭嘉俊

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

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

相关文章

OpenAI下周将发布ChatGPT搜索引擎,挑战谷歌搜索!

目前&#xff0c;多方位消息证实&#xff0c;OpenAI将会在5月9日上午10点公布该消息&#xff0c;大约是北京时间周五的凌晨2点。 5月3日&#xff0c;前Mila研究员、麻省理工讲师Lior S爆料&#xff0c;根据OpenAI最新的SSL证书日志显示&#xff0c;已经创建了search.chatgpt.c…

数据库开发关键之与DQL查询语句有关的两个案例

案例 案例1 条件分页查询 查看项目经理提供给我们的需求文档 模糊匹配的含义是 只要包含"张"就可以 use dduo;-- 按照需求完成员工管理的条件分页查询 根据输入条件 查询第一页的数据 每页展示10条记录 -- 输入条件&#xff1a; -- 姓名&#xff1a; 张 -- 年龄&…

基于Java+SpringBoot+Vue前后端分离音乐播放器系统设计与实现(有视频讲解)

博主介绍&#xff1a;✌全网粉丝5W&#xff0c;全栈开发工程师&#xff0c;从事多年软件开发&#xff0c;在大厂呆过。持有软件中级、六级等证书。可提供微服务项目搭建与毕业项目实战&#xff0c;博主也曾写过优秀论文&#xff0c;查重率极低&#xff0c;在这方面有丰富的经验…

统一大型语言模型和知识图谱:路线图

【摘要】 大型语言模型&#xff08;LLM&#xff09;&#xff0c;如ChatGPT和GPT4&#xff0c;由于其涌现能力和泛化性&#xff0c;正在自然语言处理和人工智能领域掀起新的浪潮。然而&#xff0c;LLM是黑箱模型&#xff0c;通常无法捕捉和获取事实知识。相反&#xff0c;知识图…

QQ音乐尽享海量高品质曲库,无广绿色版 PC端 v20.05.0

01 软件介绍 QQ音乐&#xff0c;作为一个在线音乐平台&#xff0c;它已经超越了传统音乐播放软件的界限&#xff0c;演变成为一个全面的音乐生态系统。该平台为用户提供了丰富的服务&#xff0c;包括无尽可能的音乐资源、定制化的个性推荐、卓越的高保真音乐体验和互动式的社交…

二叉树的迭代遍历 | LeetCode 144. 二叉树的前序遍历、LeetCode 94. 二叉树的中序遍历、LeetCode 145. 二叉树的后序遍历

二叉树的前序遍历&#xff08;迭代法&#xff09; 1、题目 题目链接&#xff1a;144. 二叉树的前序遍历 给你二叉树的根节点 root &#xff0c;返回它节点值的 前序 遍历。 示例 1&#xff1a; 输入&#xff1a;root [1,null,2,3] 输出&#xff1a;[1,2,3]示例 2&#x…

分布式锁之-redis

什么是分布式锁&#xff1f; 即分布式系统中的锁。在单体应用中我们通过锁解决的是控制共享资源访问的问题&#xff0c;而分布式锁&#xff0c;就是解决了分布式系统中控制共享资源访问的问题。与单体应用不同的是&#xff0c;分布式系统中竞争共享资源的最小粒度从线程升级成了…

NumPy库与PyTorch库的异同点

目录 1.单位的创建和操作 1.创建 2.形状变换 2.数学和统计操作 1.矩阵乘法 2.广播 3.统计计算 3.GPU支持 4.在深度学习中的作用 5.应用范围 NumPy库为数组服务&#xff0c;PyTorch库为张量服务&#xff0c;这是最本质的区别。 1.单位的创建和操作 1.创建 NumPy:使…

SQL注入-基础知识

目录 前言 一&#xff0c;SQL注入是什么 二&#xff0c;SQL注入产生的条件 三&#xff0c;学习环境介绍 四、SQL注入原理 五&#xff0c;SQL中常用的函数 六&#xff0c;关于Mysql数据库 前言 在网络安全领域中&#xff0c;sql注入是一个无法被忽视的关键点&#xff0c…

matlab例题大全

1.第一章 1.1 注&#xff1a;plot函数为画图函数。例plot&#xff08;x1,y1,:,x2,y2,*&#xff09;; 1.2 注&#xff1a;root为求根函数。p为方程变量前面系数矩阵。 1.3 注&#xff1a; 2*x3y-1*z 2; 8*x2*y3*z 4; 45*x3*y9*z 23 求&#xff1a;x,y,z的值 注&#xf…

eNSP-浮动静态路由配置

ip route-static 192.168.1.0 24 192.168.3.2 preference 60 #设置路由 目标网络地址 和 下一跳地址 preference值越大 优先级越低 一、搭建拓扑结构 二、主机配置 pc1 pc2 三、配置路由器 1.AR1路由器配置 <Huawei>sys #进入系统视图 [Huawei]int g0/0/0 #进入接…

uniapp 微信开发工具上访问正常,真机调试一直跨域报错

微信小程序真机调试时&#xff0c;出现跨域问题&#xff0c;需要同时在后端设置多种允许跨域的设置&#xff1a; // 指定允许其他域名访问 header(Access-Control-Allow-Origin:*); // 响应类型 header(Access-Control-Allow-Methods:GET,POST,OPTION); // 响应头设置 header(…

使用Python爬取淘宝商品并做数据分析

使用Python爬取淘宝商品并做数据分析&#xff0c;可以按照以下步骤进行操作&#xff1a; 确定需求&#xff1a;确定要爬取的淘宝商品的种类、数量、关键词等信息。 编写爬虫程序&#xff1a;使用Python编写爬虫程序&#xff0c;通过模拟浏览器请求&#xff0c;获取淘宝商品的页…

redis 缓存一致性,缓存穿透,缓存雪崩,缓存击穿

1.缓存一致性&#xff1a; 缓存一致性就是通过各种方法保证缓存与数据库信息一种&#xff0c;其中最多的办法就是想尽一切办法对过期key进行清除&#xff0c;以保证redis和数据库信息一只&#xff0c;其中就包括了这篇文章中提到的内存淘汰策略&#xff0c;过期key的清除等等&…

Bookends for Mac:文献管理工具

Bookends for Mac&#xff0c;一款专为学术、研究和写作领域设计的文献管理工具&#xff0c;以其强大而高效的功能深受用户喜爱。这款软件支持多种文件格式&#xff0c;如PDF、DOC、RTF等&#xff0c;能够自动提取文献的关键信息&#xff0c;如作者、标题、出版社等&#xff0c…

深入理解网络原理3----TCP核心特性介绍(上)【面试高频考点】

文章目录 前言TCP协议段格式一、确认应答【保证可靠性传输的机制】二、超时重传【保证可靠性传输的机制】三、连接管理机制【保证可靠性传输的机制】3.1建立连接&#xff08;TCP三次握手&#xff09;---经典面试题3.2断开连接&#xff08;四次挥手&#xff09;3.3TCP状态转换 四…

Delta lake with Java--在spark集群上运行程序

昨天写了第一篇入门&#xff0c;今天看见有人收藏&#xff0c;继续努力学习下去。今天要实现的内容是如何将昨天的HelloDetlaLake 在spark集群上运行&#xff0c;。具体步骤如下 1、安装spark,我使用的是 spark-3.5.1-bin-hadoop3-scala2.13&#xff0c;去官网下载&#xff0c…

微服务----nacos配置及简单使用

目录 什么是nacos 项目在nacos上进行注册 注入nacos依赖 配置application.yml文件 nacos写入配置文件 首先&#xff0c;还是需要导入依赖 然后在nacos中编写配置文件 prod是我自定义的一个命名空间&#xff0c;在这里面进行配置文件编写~ 启动类上加上注解 编写Patt…

Java与Go:并发

在此之前&#xff0c;我们先要明白什么是并发&#xff1f;为什么要并发编程&#xff1f; 在计算机中&#xff0c;同一时刻&#xff0c;只能有一条指令&#xff0c;在一个CPU上执行 后面的指令必须等到前面指令执行完才能执行&#xff0c;就是串行。在早年CPU核心数还少的时候倒…

求矩阵对角线元素之和(C语言)

一、N-S流程图&#xff1b; 二、运行结果&#xff1b; 三、源代码&#xff1b; # define _CRT_SECURE_NO_WARNINGS # include <stdio.h>int main() {//初始化变量值&#xff1b;int i 0;int j 0;int sum 0;int a[3][3] { 0 };//获取数组a的值&#xff1b;printf(&qu…