音視頻開發(fā)之旅——實現(xiàn)錄音器框产、音頻格式轉(zhuǎn)換器和播放器(PCM文件轉(zhuǎn)換為WAV文件、使用LAME編碼MP3文件)(Android)

本文章已授權(quán)微信公眾號郭霖(guolin_blog)轉(zhuǎn)載错洁。

本文主要講解的是實現(xiàn)錄音器秉宿、音頻轉(zhuǎn)換器播放器,在實現(xiàn)過程中需要把PCM文件轉(zhuǎn)換為WAV文件屯碴,同時需要使用上一篇文章交叉編譯出來的LAME庫編碼MP3文件描睦。本文基于Android平臺,示例代碼如下所示:

AndroidAudioDemo

Android系列:

音視頻開發(fā)之旅——音頻基礎(chǔ)概念导而、交叉編譯原理和實踐(LAME的交叉編譯)(Android)

iOS系列:

音視頻開發(fā)之旅——音頻基礎(chǔ)概念忱叭、交叉編譯原理和實踐(LAME的交叉編譯)(iOS)

項目主要分為三個部分:錄音器音頻格式轉(zhuǎn)換器播放器今艺。

準備工作

需求需要在上一篇文章的示例代碼上去實現(xiàn)韵丑,為了代碼規(guī)范,我們先重構(gòu)下之前的代碼虚缎,主要是以下三個過程:

  1. 重命名androidaudiodemo.cpp撵彻。

  2. CMakeLists.txt修改動態(tài)庫的名稱。

  3. 調(diào)整相關(guān)的Kotlin和C++代碼实牡。

重命名androidaudiodemo.cpp

androidaudiodemo.cpp重命名為mp3_encoder.cpp陌僵。

CMakeLists.txt修改動態(tài)庫的名稱

CMAKE_PROJECT_NAME修改為MP3Encoder,這里的CMAKE_PROJECT_NAME指的是頂級項目的名稱创坞,它是指project命令指定的項目名稱拾弃,所以之前生成的動態(tài)庫名稱為libandroidaudiodemo.so,修改后生成的動態(tài)庫名稱為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調(diào)用時搭盾,還會將項目名稱存儲在CMAKE_PROJECT_NAME變量中咳秉。

add_library命令

使用指定的源文件(例如:LAME相關(guān)的.h和.c文件)將庫添加到項目中。語法如下所示:

add_library(<name> [<type>] [EXCLUDE_FROM_ALL] <sources>...)
  • name:庫名稱鸯隅,并且在項目中必須全局唯一澜建。

  • type:這是個可選參數(shù),有STATIC蝌以、SHAREDMODULE三個類型炕舵,如果未給定這個參數(shù),就會根據(jù)BUILD_SHARED_LIBS變量的值跟畅,默認值為STATIC或者SHARED咽筋。

  • EXCLUDE_FROM_ALL:這個參數(shù)會自動設置。

  • source:指定的源文件徊件,例如:LAME相關(guān)的.h和.c文件奸攻。

STATIC

創(chuàng)建的是靜態(tài)庫

  • 文件擴展名:在Unix-like系統(tǒng)中為.a虱痕,在Windows系統(tǒng)中為.lib睹耐。

  • 鏈接方式:在編譯時鏈接到使用它的目標。

  • 適用場景:一般為小型程序和一些避免使用動態(tài)鏈接的場景部翘。

SHARED

創(chuàng)建的是動態(tài)庫硝训。

  • 文件擴展名:在Unix-like系統(tǒng)中為.so,在Windows系統(tǒng)中為.dll新思。

  • 鏈接方式:鏈接到使用它的目標捎迫,運行時動態(tài)加載。

  • 適用場景:需要共享代碼表牢。

MODULE

創(chuàng)建的是動態(tài)庫

  • 文件擴展名:在Unix-like系統(tǒng)中為.so贝次,在Windows系統(tǒng)中為.dll崔兴。

  • 鏈接方式:不直接鏈接到使用它的目標(和SHARED不同的地方),運行時動態(tài)加載蛔翅。

  • 適用場景:一般為插件系統(tǒng)敲茄,需要共享代碼。

target_link_libraries命令

指定鏈接給定的目標或者其從屬對象時需要使用的庫(libraries)或者標志(flags)山析。語法如下所示:

target_link_libraries(<target> ... <item>... ...)
  • target:這個目標必須是通過add_executable命令或者add_library命令創(chuàng)建的堰燎,并且不能是ALIAS目標。

  • item:它有可能是庫目標名稱笋轨、庫文件的完整路徑秆剪、鏈接標志赊淑、生成器表達式

調(diào)整相關(guān)的Kotlin和C++代碼

新建LameUtils類用于存放使用LAME的函數(shù)仅讽,代碼如下所示:

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函數(shù)是在LAMEUtils類的靜態(tài)代碼塊中洁灵,所以這個函數(shù)只會在LAMEUtils類第一次加載時執(zhí)行一次饱岸,之后就不會再執(zhí)行。反編譯后的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會在其內(nèi)部維護一個加載庫的緩存苫费,如果嘗試多次加載,JVM不會重新加載它双抽,只是會增加庫的引用次數(shù)百框。

最后,在MainActivity中像如下調(diào)用即可:

LAMEUtils.getLameVersion()

經(jīng)過上面的修改后荠诬,我們開始進行新需求的開發(fā)琅翻。

錄音器

我們需要使用AudioRecord相關(guān)的函數(shù),它在android.media包中柑贞,用于錄制來自麥克風方椎、耳機麥克風或者其他音頻輸入源的音頻。首先钧嘶,我們要思考錄音器大概需要有什么功能棠众?大概需要錄制音頻錄音時長有决、暫停錄音闸拿、重置釋放資源輸出數(shù)據(jù)(PCM源數(shù)據(jù)和PCM文件)這幾個功能书幕。我們新建AudioRecorder來實現(xiàn)這些需求新荤,代碼如下所示:

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

    /**
     * 是否存在已經(jīng)錄制的音頻
     *
     * @return 是否存在已經(jīng)錄制的音頻
     */
    fun hasRecordedAudio(): Boolean =
        shortArrays.isNotEmpty() || floatArrays.isNotEmpty()

    /**
     * 得到位深度為16bit的PCM音頻
     *
     * @return 音頻數(shù)據(jù)
     */
    fun getRecordedDataFor16BitPCM(): List<ShortArray> =
        shortArrays.toList()

    /**
     * 得到位深度為32bit的PCM音頻
     *
     * @return 音頻數(shù)據(jù)
     */
    fun getRecordedDataFor32BitPCM(): List<FloatArray> =
        floatArrays.toList()

    /**
     * 將錄音數(shù)據(jù)保存成文件
     *
     * @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
        }

        /**
         * 設置錄音監(jiān)聽者
         */
        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的創(chuàng)建用到了建造者模式苛骨,因為這個對象需要多個參數(shù)去創(chuàng)建,同時具備一定的靈活性苟呐,也就是說有可能某些參數(shù)是不需要的痒芝,對象的創(chuàng)建過程與其表示需要分離。在創(chuàng)建對象的時候牵素,一些必要的參數(shù)都會有默認數(shù)值严衬。在設置音頻源位深度聲道數(shù)這些參數(shù)的時候用了枚舉類進行約束笆呆,避免傳入不符合的數(shù)值请琳。

record函數(shù)

根據(jù)官方文檔描述粱挡,錄制不同的位深度音頻,需要調(diào)用對應的record重載函數(shù)单起,如下所示:

如果要錄制位深度為16bit的音頻抱怔,需要寫入到short數(shù)組,官方描述也可以寫入到byte數(shù)組嘀倒,但是不推薦使用屈留,代碼如下所示:

// AudioRecord.java
// 不推薦使用該函數(shù)錄制位深度為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數(shù)組测蘑,代碼如下所示:

// 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);
    }

計算錄音時長

錄音時長可以通過音頻比特率總字節(jié)長度計算出來灌危,代碼如下所示:

// AudioUtils.kt
/**
 * 得到音頻時長,單位:秒碳胳。
 *
 * @param byteLength 字節(jié)長度
 * @param sampleRateInHz 采樣率勇蝙,單位:赫茲
 * @param bitDepth 位深度
 * @param channelCount 聲道數(shù)
 * @return 音頻時長,單位:秒
 */
@JvmStatic
fun getAudioDurationInSec(
    byteLength: Long,
    sampleRateInHz: Int,
    bitDepth: Int,
    channelCount: Int
): Long {
    val bitRate = sampleRateInHz * bitDepth * channelCount
    return byteLength * 8 / bitRate
}

采樣率(單位:赫茲) * 位深度 * 聲道數(shù) = 比特率

因為比特率用于衡量音頻數(shù)據(jù)單位時間內(nèi)的容量大小挨约,也就是一秒時間內(nèi)的比特數(shù)目味混,所以:

總字節(jié)長度 * 8 / 比特率 = 音頻時長

AcousticEchoCanceler

AcousticEchoCanceler聲學回聲消除器,簡稱AEC诫惭,它是一種音頻預處理器翁锡,可以從捕獲的音頻信號中去除從遠程方接收到的信號的影響。AEC用于語音通信應用夕土,例如:語音聊天馆衔、視頻會議或者SIP呼叫,在這些應用中怨绣,從遠程方接收到的信號中存在著回聲和顯著的延遲會令人非常不按角溃。它通常和嗓音抑制器(NS)結(jié)合使用。

在啟用之前檢查下設備是否支持AEC篮撑,然后創(chuàng)建AcousticEchoCanceler减细,并且將其通過音頻會話id附加到指定的音頻。

AutomaticGainControl

AutomaticGainControl自動增益控制赢笨,簡稱AGC未蝌,它是一種音頻預處理器,可以通過升高或者降低麥克風的輸入來自動標準化捕獲信號的輸出质欲,以匹配預設電平,從而使輸出信號電平幾乎恒定糠馆。AGC用于輸入信號動態(tài)范圍并不重要嘶伟,但是需要恒定的強捕獲電平的應用。

我們常用dBFS(分貝全幅波形)來表示音頻信號的電平又碌,理想的錄音電平通常在-12dBFS~-18dBFS之間九昧,以確保有足夠的動態(tài)范圍绊袋,避免信號過載或者失真。

在啟用之前檢查下設備是否支持AGC铸鹰,然后創(chuàng)建AutomaticGainControl癌别,并且將其通過音頻會話id附加到指定的音頻。

NoiseSuppressor

NoiseSuppressor噪聲抑制器蹋笼,簡稱NS展姐,它是一種從捕獲的音頻信號中去除背景噪聲的音頻預處理器。噪聲可以分為靜止的剖毯,例如:汽車或者飛機發(fā)動機聲音圾笨,它們是有一定規(guī)律的;也可以分為非靜止的逊谋,例如:其他人的對話擂达,它們沒什么規(guī)律。NS用于語音通信應用胶滋,例如:語音聊天板鬓、視頻會議或者SIP呼叫。

在啟用之前檢查下設備是否支持NS究恤,然后創(chuàng)建NoiseSuppressor俭令,并且將其通過音頻會話id附加到指定的音頻。

輸出數(shù)據(jù)

該錄音器支持輸出兩種數(shù)據(jù)丁溅,分別是PCM源數(shù)據(jù)PCM文件唤蔗。

PCM源數(shù)據(jù)會根據(jù)音頻位深度返回不同類型的List,位深度為16bit會返回short數(shù)組的List窟赏,位深度為32bit會返回float數(shù)組的List妓柜,目的是方便AudioTrack回放音頻授翻,因為它也是根據(jù)位深度不同瓢谢,有不一樣的寫數(shù)據(jù)函數(shù),這個后面會提到数初。要注意的是拷况,它們都通過調(diào)用可變的MutableList的toList函數(shù)作煌,返回的是一個新的不可變的List,這樣做的目的是防止使用AudioRecorder的類因為持有這個List的引用赚瘦,導致可以修改它的數(shù)據(jù)粟誓,出現(xiàn)臟數(shù)據(jù),影響到AudioRecorder的表現(xiàn)起意,而且也不利于調(diào)試代碼鹰服,這種暴露對象引用的做法是違反了封裝原則,它會使得對象的內(nèi)部狀態(tài)容易被外部狀態(tài)影響,從而破壞對象的一致性和狀態(tài)安全悲酷。

saveDataAsPCM函數(shù)

我們看到該函數(shù)寫入文件的時候用到了FileOutputStream(文件輸出流)套菜,并且使用BufferedOutputStream(緩沖輸出流)提高寫入速度,通過write函數(shù)將數(shù)據(jù)寫入緩沖區(qū)设易,當緩沖區(qū)滿時才一次性寫入文件逗柴,減少磁盤的寫入次數(shù),從而提高效率顿肺。我們在讀取文件的時候也可以使用相對應的FileInputStream(文件輸入流)BufferedInputStream(緩沖輸入流)戏溺,它是通過read函數(shù)一次性從文件讀取多個字節(jié)到緩沖區(qū)中,后續(xù)的讀取操作實際上是從緩沖區(qū)讀取數(shù)據(jù)挟冠,減少磁盤的讀取次數(shù)于购,從而提高效率。

我們還看到該函數(shù)使用到use函數(shù)知染,它可以幫我們正確地關(guān)閉使用的資源(無論是否產(chǎn)生異常)肋僧,而且方便我們查看異常,避免異常屏蔽控淡。我們使用Kotlin處理資源的時候要優(yōu)先考慮使用這個函數(shù)嫌吠,源碼如下所示:

// 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的時候掺炭,都要手工調(diào)用close函數(shù)來關(guān)閉資源辫诅,在Java 7之前采用的是try-finally語句來關(guān)閉資源,但是在多個資源的時候涧狮,try-finally語句就需要不斷嵌套炕矮,可讀性很差,而且就算這樣做能正確地關(guān)閉了資源者冤,但是這種寫法還是存在著不足肤视,try塊和finally塊都有可能會拋出異常,如果同時拋出異常涉枫,那么第二個異常會完全抹掉第一個異常邢滑,在異常堆棧軌跡中是完全找不到第一個異常的記錄,第一個異常被屏蔽了愿汰,這導致調(diào)試變得非常復雜困后,示例代碼如下所示:

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語句,它可以解決上面所說的所有問題衬廷,使用它優(yōu)化上面的示例代碼摇予,示例代碼如下所示:

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);
        }
    }
}

可以看到代碼可讀性得到很大的提升,同時解決了上面提到的異常屏蔽的問題吗跋。我們看到上面使用的輸入流輸出流都進行了向上轉(zhuǎn)型操作侧戴,轉(zhuǎn)為其父類,這是遵循里氏替換原則,這個原則的核心是子類對象可以替換程序中父類對象出現(xiàn)的任何地方救鲤,并且保證程序的正確性。

音頻格式轉(zhuǎn)換器

上面也提到秩冈,錄音器錄制完音頻可以選擇輸出兩種數(shù)據(jù)本缠,分別是PCM源數(shù)據(jù)和PCM文件,PCM是不能直接通過播放器播放的入问,因為它是音頻的裸數(shù)據(jù)格式丹锹,想要播放的話,可以通過將音頻轉(zhuǎn)成數(shù)據(jù)流(也就是我們錄音器輸出的PCM源數(shù)據(jù)芬失,當然也可以把PCM文件轉(zhuǎn)成數(shù)據(jù)流)楣黍,使用AudioTrack指定采樣率、位深度和聲道數(shù)等參數(shù)信息后進行播放棱烂,除此之外租漂,還可以把PCM文件轉(zhuǎn)換成WAV文件或者MP3文件,AudioFormatConverter就是用來處理這些邏輯颊糜。

PCM文件轉(zhuǎn)換為WAV文件

WAV(Waveform Audio File Format)是微軟專門為Windows開發(fā)的一種編碼格式哩治,它會在PCM數(shù)據(jù)格式的前面加上44字節(jié),分別用來描述該PCM數(shù)據(jù)的采樣率衬鱼、聲道數(shù)业筏、量化格式。

WAV由若干個塊(Chunk)組成鸟赫,規(guī)范如下圖所示:

wav_sound_format.gif

整理成表格蒜胖,如下所示:

偏移地址 字段大小 字段名稱 字段描述 字節(jié)序
0~3 4 ChunkID 字母“RIFF” 大端
4~7 4 ChunkSize 總數(shù)據(jù)大小: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 聲道數(shù) 小端
24~27 4 SampleRate 采樣率 小端
28~31 4 ByteRate 字節(jié)率:采樣率 * 位深度 / 8 * 聲道數(shù) 小端
32~33 2 BlockAlign 每次采樣的大小:聲道數(shù) * 位深度 / 8 小端
34~35 2 BitsPerSample 每個采樣的位數(shù) 小端
36~39 4 Subchunk2ID 字母“data” 大端
40~43 4 Subchunk2Size 音頻數(shù)據(jù)的大小 小端
44~…… * Data 音頻數(shù)據(jù) 小端

根據(jù)上面的描述诉位,我們轉(zhuǎn)換成代碼骑脱,代碼如下所示:

// AudioFormatConverter.kt
/**
 * 將PCM文件轉(zhuǎn)換為WAV文件
 *
 * @param inputPCMFilePath 輸入的PCM文件路徑
 * @param outputWAVFilePath 輸出的WAV文件路徑
 * @param sampleRateInHz 采樣率,單位:頻率
 * @param bitDepth 位深度
 * @param channelCount 聲道數(shù)
 * @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:音頻數(shù)據(jù)
                        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數(shù)據(jù)大小
 * @param sampleRateInHz 采樣率苍糠,單位:頻率
 * @param bitDepth 位深度
 * @param channelCount 聲道數(shù)
 * @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 音頻數(shù)據(jù)的大小
 * @param sampleRateInHz 采樣率叁丧,單位:頻率
 * @param bitDepth 位深度
 * @param channelCount 聲道數(shù)
 * @return 字節(jié)數(shù)組
 * @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()
    /**
     * 總數(shù)據(jù)大小:36+Subchunk2Size(值為PCM文件大小,也就是totalAudioSize)拥娄,更準確地說就是
     * 4 + (8 + Subchunk1Size(值為16)) + (8 + Subchunk2Size(值為PCM文件大小蚊锹,也就是totalAudioSize))
     */
    val totalDataSize = 36 + totalAudioSize
    // ChunkSize:總數(shù)據(jù)大小
    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:聲道數(shù)
    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()
    // 字節(jié)率:采樣率 * 位深度 / 8 * 聲道數(shù)
    val byteRate: Long = (sampleRateInHz * bitDepth / 8 * channelCount).toLong()
    // ByteRate:字節(jié)率
    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()
    // 每次采樣的大卸妗:聲道數(shù) * 位深度 / 8
    val blockAlign: Int = channelCount * bitDepth / 8
    // BlockAlign:每次采樣的大小
    header[32] = blockAlign.toByte()
    header[33] = 0
    // BitsPerSample:每個采樣的位數(shù)
    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:音頻數(shù)據(jù)的大小
    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
}

題外話

  • 小端字節(jié)序:低位字節(jié)排在內(nèi)存的低地址端高位字節(jié)排在內(nèi)存的高地址端些椒。

  • 大端字節(jié)序:高位字節(jié)排在內(nèi)存的低地址端播瞳,低位字節(jié)排在內(nèi)存的高地址端

假設有一個十六進制的整型數(shù)據(jù)0x01234567免糕,要寫入到地址為0x00001000~0x00001003中赢乓,它們的區(qū)別如下所示,其中第一行是地址石窑,第二行是數(shù)據(jù)骏全。

小端字節(jié)序如下所示:

0x00001000 0x000010001 0x00001002 0x00001003
67 45 23 01

大端字節(jié)序如下所示:

0x00001000 0x000010001 0x00001002 0x00001003
01 23 45 67

如果需要從最低位開始運算或者需要逐位運算,例如:檢查奇偶性尼斧、比較大小姜贡、加法乘法或者更改數(shù)據(jù)類型棺棵,那么小端字節(jié)序是有優(yōu)勢楼咳;如果需要涉及到高位運算,例如:檢查正負號烛恤,那么大端字節(jié)序是有優(yōu)勢母怜。大端字節(jié)序比較符合大部分國家的閱讀習慣(從左到右),所以它的可讀性更好缚柏。

主機字節(jié)序是和CPU有關(guān)的苹熏,Intel和AMD這兩個架構(gòu)使用的是小端字節(jié)序。Java虛擬機(JVM)字節(jié)序通常和運行JVM的硬件架構(gòu)有關(guān)币喧,它會根據(jù)硬件架構(gòu)自動轉(zhuǎn)換轨域,一般來說它是小端字節(jié)序。另外杀餐,由于TCP/IP協(xié)議(RFC 1700文檔)規(guī)定使用大端字節(jié)序作為網(wǎng)絡字節(jié)序干发,這意味著,當我們在網(wǎng)絡上發(fā)送或者接收數(shù)據(jù)的時候史翘,JVM會自動處理字節(jié)序的轉(zhuǎn)換枉长,以確保數(shù)據(jù)在源和目的之間正確進行序列化和反序列化冀续。

使用LAME編碼MP3文件

我們使用上一篇文章交叉編譯出來的LAME庫編碼MP3文件,需要使用到LAME庫大概三個功能:初始化必峰、編碼銷毀洪唐。我們使用上面新建的LameUtils類增加相關(guān)的函數(shù),并且在mp3_encoder.cpp為這些函數(shù)生成JNI函數(shù)吼蚁。

初始化

要想用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 聲道數(shù)
     * @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轉(zhuǎn)換為UTF-8編碼的字符數(shù)組;因為不需要關(guān)心JVM是否會返回原始字符串的副本溅潜,所以isCopy參數(shù)傳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轉(zhuǎn)換為UTF-8編碼的字符數(shù)組;因為不需要關(guān)心JVM是否會返回原始字符串的副本滚澜,所以isCopy參數(shù)傳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編碼器的聲道數(shù)
    lame_set_num_channels(lameClient, channel_count);
    lame_init_params(lameClient);
    return true;
}

// 省略部分代碼

在JNI中,Java字符串C/C++字符串之間的轉(zhuǎn)換需要特別處理设捐,因為Java字符串Unicode(統(tǒng)一碼)的借浊,它是為了解決傳統(tǒng)的字符編碼方案的局限而產(chǎn)生的,為每種語言中每個字符設定了統(tǒng)一并且唯一的二進制編碼萝招,以滿足跨語言蚂斤、跨平臺進行文本轉(zhuǎn)換、處理的要求槐沼;C/C++字符串通常是以字符形式存儲的曙蒸。從Java字符串轉(zhuǎn)換到C/C++字符串,或者從C/C++字符串轉(zhuǎn)換到Java字符串涉及到字符編碼內(nèi)存管理啊的問題岗钩,直接處理這些轉(zhuǎn)換可能會導致字符串損壞纽窟、內(nèi)存泄露等問題,所以JNI提供了一些工具函數(shù)來簡化這個過程兼吓,例如:GetStringUTFChars函數(shù)和NewStringUTF函數(shù)臂港,它們提供了自動化的字符串轉(zhuǎn)換內(nèi)存管理,確保了原生代碼不會因為不當?shù)奶幚矶茐腏ava字符串视搏,并且保證了字符串的正確轉(zhuǎn)換和釋放审孽。

我們看到該函數(shù)是以讀取二進制文件的方式打開需要輸入的PCM文件,以寫入二進制文件的方式打開需要輸出的MP3文件浑娜。fopen函數(shù)第二個參數(shù)是用來指定文件訪問模式瓷胧,它是個字符數(shù)組,有如下幾種模式:

字符串 說明
r 只讀模式棚愤,打開一個已存在的文件搓萧,并且文件必須存在杂数,從文件的開頭開始讀。
r+ 讀寫模式瘸洛,打開一個已存在的文件揍移,并且文件必須存在,從文件的開頭開始讀寫反肋。
w 寫入模式那伐,如果文件已存在,就把文件長度清為零石蔗,即文件內(nèi)容會清空罕邀;如果文件不存在,就創(chuàng)建該文件养距。
a 追加模式诉探,如果文件已存在,就把寫入的數(shù)據(jù)追加到文件尾后棍厌,也就是文件原先的內(nèi)容會被保留肾胯,保留EOF符;如果文件不存在耘纱,就創(chuàng)建該文件敬肚。
a+ 追加模式,如果文件已存在束析,就把寫入的數(shù)據(jù)追加到文件尾后艳馒,也就是文件原先的內(nèi)容會被保留,不保留EOF符员寇;如果文件不存在鹰溜,就創(chuàng)建該文件。
x 創(chuàng)建并寫入丁恭,如果文件已存在曹动,fopen函數(shù)返回NULL,并且失敗錯誤代碼會被設置為EEXIST牲览。
x+ 創(chuàng)建并讀寫墓陈,如果文件已存在,fopen函數(shù)返回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數(shù)據(jù)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
        );
        // 將編碼后的數(shù)據(jù)寫入MP3文件中
        fwrite(mp3Buffer, 1, wroteSize, outputMP3File);
    }
    // 釋放內(nèi)存卖怜,并且調(diào)用對象數(shù)組的析構(gòu)函數(shù)
    delete[] buffer;
    delete[] leftBuffer;
    delete[] rightBuffer;
    delete[] mp3Buffer;
}

// 省略部分代碼

核心邏輯就是代碼里的一個循環(huán),它每次從PCM文件讀取一段bufferSize大小的PCM數(shù)據(jù)buffer,然后把該buffer的左右聲道拆分開虑粥,通過lame_encode_buffer函數(shù)將左聲道buffer右聲道buffer送入到LAME編碼器進行編碼娩贷,最后將編碼后的數(shù)據(jù)寫入MP3文件中彬祖。

銷毀

最后品抽,記得要關(guān)閉先前打開的文件圆恤,把緩沖區(qū)內(nèi)最后剩余的數(shù)據(jù)輸出到內(nèi)核緩沖區(qū)盆昙,并且釋放文件指針和相關(guān)的緩沖區(qū)淡喜,同時銷毀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;
    }
    // 關(guān)閉先前打開的PCM文件,把緩沖區(qū)內(nèi)最后剩余的數(shù)據(jù)輸出到內(nèi)核緩沖區(qū)币叹,并且釋放文件指針和相關(guān)的緩沖區(qū)
    fclose(inputPCMFile);
    if (!outputMP3File) {
        return;
    }
    // 關(guān)閉先前打開的MP3文件颈抚,把緩沖區(qū)內(nèi)最后剩余的數(shù)據(jù)輸出到內(nèi)核緩沖區(qū)贩汉,并且釋放文件指針和相關(guān)的緩沖區(qū)
    fclose(outputMP3File);
    if (!lameClient) {
        return;
    }
    // 銷毀LAME編碼器
    lame_close(lameClient);
}

播放器

該播放器分為兩種模式:AudioPCMPlayerAudioPlayer

Android的SDK(指的是Java層提供的API)提供了三套音頻播放的API:AudioTrack褐鸥、MediaPlayerSoundPool叫榕。

  • AudioTrack:它是最底層的音頻播放API晰绎,只允許輸入裸數(shù)據(jù)荞下,適合低延遲的播放史飞,提供了非常強大的控制能力构资,適合流媒體的播放等場景吐绵。由于它是最底層的API拦赠,所以需要結(jié)合解碼器來使用。

  • MediaPlayer:適合在后臺長時間播放本地音樂文件或者在線的流式媒體文件,它的封裝層次比較高矮嫉,使用起來比較簡單蠢笋。

  • SoundPool:適合播放比較短的音頻昨寞,或者需要重復播放的音頻援岩,例如:游戲聲音享怀、按鍵聲音或者鈴聲等等添瓷,它可以同時播放多個音頻鳞贷。它的底層是通過OpenSL ES來實現(xiàn)的悄晃,通過JNI與底層的MediaPlayer進行交互的妈橄,本質(zhì)上還是使用MediaPlayer來解碼并且播放眷蚓,只是它會將音頻數(shù)據(jù)緩存在內(nèi)存中沙热,同時為每一個音頻生成對應一個索引號篙贸,以后每次播放的時候就根據(jù)索引號找到內(nèi)存中對應的音頻進行解碼播放爵川。它用到了池化技術(shù)寝贡,提高了資源的利用率圃泡,池化技術(shù)有這四個優(yōu)點:節(jié)約資源颇蜡、優(yōu)化響應時間澡匪、更好地控制資源的數(shù)量更好地預測系統(tǒng)的性能唁情。

SoundPool相對于MediaPlayer來說甸鸟,大大提高響應性同時減少了CPU計算開銷抢韭,這種策略屬于用空間換時間刻恭,凡事有兩面性鳍贾,當然這也是有相對應的缺點橡淑,那就是如果是很長的音頻梁棠,那么產(chǎn)生的緩存就會很大符糊,占用的資源就很多男娄,所以它適合比較短的音頻瓮顽。

除此之外暖混,還可以使用ExoPlayer播放拣播,它是Jetpack Media3中的Player接口的默認實現(xiàn)贮配,和MediaPlayer的API相比泪勒,它增加了額外的便利性圆存,例如:支持多種流式傳輸協(xié)議沦辙、默認音頻和視頻渲染程序以及處理媒體緩沖的組件。

Android的NDK提供了OpenSL ES的C語言的接口陌兑,可以提供非常強大的音效處理诀紊、低延遲播放等功能,例如:在Android手機上實現(xiàn)實時耳返的功能为居。

AudioPCMPlayer

AudioPCMPlayer使用AudioTrack指定采樣率蒙畴、位深度和聲道數(shù)等參數(shù)信息后播放數(shù)據(jù)流(也就是我們錄音器輸出的PCM源數(shù)據(jù))碑隆∩厦海總體設計也是用到了建造者模式永部,這里只列出播放位深度為16bit的PCM音頻的核心代碼,代碼如下所示:

// AudioPCMPlayer.kt
/**
 * 播放位深度為16bit的PCM音頻
 *
 * @param audioData 音頻數(shù)據(jù)
 * @param listener 音頻播放監(jiān)聽器
 */
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. 根據(jù)音頻配置信息(例如:采樣率晨炕、位深度和聲道數(shù)等等)創(chuàng)建一個AudioTrack對象削罩。

  2. 調(diào)用AudioTrack的play函數(shù)微服,將AudioTrack切換到播放狀態(tài)辛孵。

  3. 啟動IO線程宝与,循環(huán)向AudioTrack的緩沖區(qū)中寫入音頻數(shù)據(jù)。

  4. 音頻數(shù)據(jù)寫完或者停止播放的時候,停止對應的IO線程,并且釋放所有資源。

要注意的是逻澳,在創(chuàng)建AudioTrack的時候,有個TransferMode(傳輸模式)需要設置,它有兩個模式分別是:MODE_STATICMODE_STREAM诱告,MODE_STATIC需要一次性將所有的數(shù)據(jù)寫入播放緩沖區(qū)中,通常用于播放比較短的音頻刮便,例如:鈴聲坝疼、系統(tǒng)提醒聲唁影;MODE_STREAM需要按照一定的時間間隔不間斷地寫入音頻數(shù)據(jù),可以應用于任何音頻播放的場景孔祸,我們的播放器就是使用它惶室。創(chuàng)建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 音頻播放監(jiān)聽器
 */
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)前需要請求運行時權(quán)限,需要以下權(quán)限:

  • Android版本大于等于13(API Level >= 33)需要READ_MEDIA_AUDIO(讀取媒體音頻),反之需要READ_EXTERNAL_STORAGE(讀取外部存儲)和WRITE_EXTERNAL_STORAGE(寫入外部存儲)踪央。

  • RECORD_AUDIO(錄制音頻)。

使用AndroidX庫中RequestPermission相關(guān)的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()
        }
    }

確認所有權(quán)限已經(jīng)獲得后讶请,就可以進入音頻編輯頁夺溢,該頁面大概的流程:使用錄音,錄完音后可以直接播放音頻試聽烛谊,還可以把音頻數(shù)據(jù)保存為PCM文件风响,同時顯示PCM文件路徑丹禀,然后可以轉(zhuǎn)換為WAV文件或者MP3文件播放状勤,同樣的,也會顯示對應文件的路徑了双泪;可以隨時暫统炙眩或者繼續(xù)錄音,在不退出該頁面的情況下焙矛,可以在上次的錄音數(shù)據(jù)后面繼續(xù)錄音葫盼;在保存和轉(zhuǎn)換音頻過程中,因為是耗時操作村斟,所以會顯示相關(guān)的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:譚嘉俊

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末抛猫,一起剝皮案震驚了整個濱河市念赶,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌锹淌,老刑警劉巖让虐,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異址儒,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進店門面哥,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人毅待,你說我怎么就攤上這事尚卫。” “怎么了尸红?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵吱涉,是天一觀的道長。 經(jīng)常有香客問我外里,道長怎爵,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任盅蝗,我火速辦了婚禮鳖链,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘墩莫。我一直安慰自己芙委,他們只是感情好,可當我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布狂秦。 她就那樣靜靜地躺著灌侣,像睡著了一般。 火紅的嫁衣襯著肌膚如雪裂问。 梳的紋絲不亂的頭發(fā)上顶瞳,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天,我揣著相機與錄音愕秫,去河邊找鬼慨菱。 笑死,一個胖子當著我的面吹牛戴甩,可吹牛的內(nèi)容都是我干的符喝。 我是一名探鬼主播,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼甜孤,長吁一口氣:“原來是場噩夢啊……” “哼协饲!你這毒婦竟也來了畏腕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤茉稠,失蹤者是張志新(化名)和其女友劉穎描馅,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體而线,經(jīng)...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡铭污,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了膀篮。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片嘹狞。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖誓竿,靈堂內(nèi)的尸體忽然破棺而出磅网,到底是詐尸還是另有隱情,我是刑警寧澤筷屡,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布涧偷,位于F島的核電站,受9級特大地震影響毙死,放射性物質(zhì)發(fā)生泄漏燎潮。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一规哲、第九天 我趴在偏房一處隱蔽的房頂上張望跟啤。 院中可真熱鬧,春花似錦唉锌、人聲如沸隅肥。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽腥放。三九已至,卻和暖如春绿语,著一層夾襖步出監(jiān)牢的瞬間秃症,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工吕粹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留种柑,地道東北人。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓匹耕,卻偏偏與公主長得像聚请,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,722評論 2 345

推薦閱讀更多精彩內(nèi)容