1 音頻采集流程
聲音是由物體振動產(chǎn)生的聲波囱嫩,是通過介質(zhì)(空氣或固體、液體)傳播并能被人或動物聽覺器官所感知的波動現(xiàn)象。聲波是一種在時(shí)間和振幅上連續(xù)的模擬量破喻,麥克風(fēng)就是一種采集聲波并將其轉(zhuǎn)換成模擬電壓信號輸出的裝置,有了聲波的模擬電壓信號盟榴,下一步需要將模擬信號數(shù)字化曹质,即將模擬信號通過模數(shù)轉(zhuǎn)換器(A/D)后轉(zhuǎn)換成數(shù)字信號,最常見的模數(shù)轉(zhuǎn)換方式就是脈沖編碼調(diào)制PCM(Pulse Code Modulation)擎场,PCM編碼過程如下圖所示:
從上圖中可以看到PCM編碼主要有三個(gè)過程:采樣羽德、量化、編碼迅办。
1> 采樣
將時(shí)間連續(xù)的模擬信號按照采樣率提取樣值宅静,變?yōu)闀r(shí)間軸上離散的抽樣信號的過程。采樣率是每秒從模擬信號中提取樣值的次數(shù)站欺。Nyquist–Shannon(奈奎斯特-香農(nóng))采樣定律表明如果至少以模擬信號最高頻率2倍的采樣率對模擬信號進(jìn)行均勻采樣姨夹,那么原始模擬信號才能不失真的從采樣產(chǎn)生的離散值中完全恢復(fù)。人耳可以聽到的聲波頻率范圍是 20Hz~22.05kHz矾策,因此44.1kHz/16bit的音頻數(shù)據(jù)被認(rèn)為是無損音頻磷账。
2> 量化
抽樣信號雖然是時(shí)間軸上離散的信號,但仍然是模擬信號贾虽,其樣值在一定的取值范圍內(nèi)逃糟,可有無限多個(gè)值。顯然,對無限個(gè)樣值給出數(shù)字碼組來對應(yīng)是不可能的绰咽。為了實(shí)現(xiàn)以數(shù)字碼表示樣值菇肃,必須采用“四舍五入”的方法把樣值分級“取整”,使一定取值范圍內(nèi)的樣值由無限多個(gè)值變?yōu)橛邢迋€(gè)值剃诅。這一過程稱為量化巷送。
量化后的抽樣信號與量化前的抽樣信號相比較,當(dāng)然有所失真矛辕,且不再是模擬信號笑跛。這種量化失真在接收端還原模擬信號時(shí)表現(xiàn)為噪聲,并稱為量化噪聲聊品。量化噪聲的大小取決于把樣值分級“取整”的方式飞蹂,分的級數(shù)越多,即量化級差或間隔越小翻屈,量化噪聲也越小陈哑。
3> 編碼
量化后的抽樣信號就轉(zhuǎn)化為按抽樣時(shí)序排列的一串十進(jìn)制數(shù)字碼流,即十進(jìn)制數(shù)字信號伸眶。簡單高效的數(shù)據(jù)系統(tǒng)是二進(jìn)制碼系統(tǒng)惊窖,因此應(yīng)將十進(jìn)制數(shù)字代碼變換成二進(jìn)制編碼。這種把量化的抽樣信號變換成給定字長(采樣位數(shù))的二進(jìn)制碼流的過程稱為編碼
經(jīng)過上面的PCM編碼過程得到的數(shù)字信號就是 PCM音頻數(shù)據(jù)厘贼。
在PCM編碼過程中主要用3個(gè)參數(shù)表現(xiàn)PCM音頻數(shù)據(jù):采樣率界酒、采樣位數(shù)以及聲道數(shù),
其中采樣率嘴秸、采樣位數(shù)上面已經(jīng)講解過毁欣,通道數(shù)即采集聲音的通道數(shù),有單聲道(mono)和立體聲(雙聲道stereo)等岳掐,聲道數(shù)越多越能體現(xiàn)聲音的空間立體效果凭疮。
2 PCM音頻數(shù)據(jù)的存儲方式
采集的PCM音頻數(shù)據(jù)是需要保存到本地文件中,如果用單聲道采集的串述,則按時(shí)間的先后順序依次存入执解,如果是雙聲道的話則按時(shí)間先后順序交叉地存入,如下圖所示:
PCM音頻數(shù)據(jù)一般無法通過播放器直接播放剖煌〔酿校可以使用ffplay工具進(jìn)行播放
ffplay -f s16le -ar 44100 -ac 1 -i raw.pcm
參數(shù)解釋
-f s16le: 設(shè)置音頻格式為有符號16位小端格式(signed 16 bits little endian),對應(yīng)Android中的AudioFormat.ENCODING_PCM_16BIT
-ar 44100 :設(shè)置音頻采樣率(audiorate)為44100
-ac 1:設(shè)置聲道數(shù)(audiochannels)1,單聲道為1耕姊,雙聲道為2
-i raw.pcm :設(shè)置輸入的pcm音頻文件
通常將PCM音頻數(shù)據(jù)轉(zhuǎn)化為WAVE文件就可以用播放器直接解析播放桶唐,WAVE是微軟公司專門為Windows開發(fā)的一種標(biāo)準(zhǔn)數(shù)字音頻文件,該文件能記錄各種單聲道或立體聲的聲音信息茉兰,并能保證聲音不失真尤泽。它符合資源互換文件格式(RIFF)規(guī)范。
RIFF文件(符合RIFF規(guī)范的文件)是windows環(huán)境下大部分多媒體文遵循的一種文件結(jié)構(gòu),RIFF文件所包含的數(shù)據(jù)類型由該文件的擴(kuò)展名來標(biāo)識,能以RIFF文件存儲的數(shù)據(jù)包括:音頻視頻交錯(cuò)格式數(shù)據(jù)(.AVI)坯约、 波形格式數(shù)據(jù)(.WAV) 熊咽、位圖格式數(shù)據(jù)(.RDI)、 MIDI格式數(shù)據(jù)(.RMI)闹丐、調(diào)色板格式(.PAL)横殴、多媒體電影(.RMN)、動畫光標(biāo)(.ANI)等卿拴,RIFF文件結(jié)構(gòu)如下圖所示:
如上圖所示衫仑,chunk是構(gòu)成RIFF文件的基本單元,RIFF文件是由chunk嵌套構(gòu)成堕花,RIFF文件首先存放的必須是一個(gè)RIFF chunk文狱,并且只能有這一個(gè)標(biāo)志為RIFF的chunk。chunk的詳細(xì)說明如下:
ID: 塊的唯一標(biāo)識缘挽,其值可為RIFF,LIST,fmt,fact,data等瞄崇。
Size: 塊中Data的大小,以字節(jié)為單位壕曼。
Data: 塊中的實(shí)際數(shù)據(jù)苏研。
只有ID為RIFF或者LIST的chunk才能包含其他的chunk,ID為RIFF的chunk中Data的起始位置的FormType用于標(biāo)識Data中的chunk的數(shù)據(jù)類型腮郊。
WAVE文件中chunk的排列方式依次是:RIFF chunk(FormType 為 WAVE)楣富,F(xiàn)ormat sub-chunk,F(xiàn)act sub-chunk(附加塊伴榔,可選,采用壓縮編碼的WAVE文件庄萎,必須要有Fact chunk踪少,該塊中只有一個(gè)數(shù)據(jù),為每個(gè)聲道的采樣總數(shù))糠涛,Data chunk援奢。接下來我們看看WAVE文件結(jié)構(gòu),如下圖所示:
Data sub-chunk中的Data中存放具體的音頻數(shù)據(jù)忍捡,將PCM音頻數(shù)據(jù)轉(zhuǎn)換成WAV音頻文件實(shí)際上就是把PCM音頻數(shù)據(jù)放到該位置集漾。
3 Android上采集和播放PCM音頻數(shù)據(jù)
有了上面的理論基礎(chǔ),接下來就在Android手機(jī)上實(shí)現(xiàn)一下砸脊,使用AudioRecord采集PCM音頻數(shù)據(jù)的代碼實(shí)現(xiàn):
private var audioRecord: AudioRecord? = null
private const val sampleRateInHz: Int = 44100
private const val bitsPerSample: Int = 16
private const val channelConfig = AudioFormat.CHANNEL_IN_MONO
private const val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val bufferSize =
AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
private var pcmFile: File? = null
private var mScope: CoroutineScope? = null
/**
* 創(chuàng)建音頻錄制器
*
* @author cytmxk
* @since 2021/10/12
*/
private fun createAudioRecord(): AudioRecord {
Log.d(TAG, "createAudioRecord: bufferSize = $bufferSize");
// audioSource: 音頻來源具篇,MediaRecorder.AudioSource.MIC 代表來源于麥克風(fēng)
// sampleRateInHz: 采樣率,每秒取得聲音樣本的次數(shù)凌埂,采樣頻率越高驱显,聲音的質(zhì)量也就越好,還原的聲音就越真實(shí),但同時(shí)它占用的資源越多埃疫。常見的采樣率為44100 即44.1KHZ
// channelConfig: 聲道配置伏恐,分為單聲道和立體聲道,CHANNEL_IN_MONO代表單聲道栓霜,CHANNEL_IN_STEREO代表立體聲道
// audioFormat: 音頻格式翠桦,ENCODING_PCM_16BIT代表通過PCM進(jìn)行采樣編碼,采樣的大小為16位
// bufferSizeInBytes: 音頻采集緩沖區(qū)大小,計(jì)算公式為 采樣率 x 位寬 x 采樣時(shí)間 x 通道數(shù)申屹,采樣時(shí)間一般取 2.5ms~120ms 之間坪蚁,
// 由廠商或者具體的應(yīng)用決定,采樣時(shí)間取得越短碎片化的數(shù)據(jù)也就會越多闻鉴,開發(fā)中使用getMinBufferSize()方法的返回值,
// 使用比getMinBufferSize()小的值則會導(dǎo)致初始化失敗茂洒。
return AudioRecord(
MediaRecorder.AudioSource.MIC, sampleRate,
channelConfig, audioFormat,
bufferSize
)
}
/**
* 開始音頻錄制
*
* @author cytmxk
* @since 2021/10/12
*/
public fun startRecord() {
stopRecord()
audioRecord = createAudioRecord()
Log.d(TAG, "captureByAudioRecord: state = ${audioRecord!!.state}")
// 判斷視頻錄制器是否初始化成功
if (AudioRecord.STATE_INITIALIZED != audioRecord!!.state) {
Log.d(TAG, "AudioRecord無法初始化孟岛,請檢查錄制權(quán)限或者是否其他app沒有釋放錄音器")
}
// 創(chuàng)建用于保存采集的pcm音頻數(shù)據(jù)的文件
pcmFile = MediaFileUtils.getAudioFile("test.pcm")
Log.d(TAG, "initPCMFile: pcmFile=$pcmFile")
pcmFile ?: return
if (pcmFile!!.exists()) {
pcmFile!!.delete()
}
// 開始采集pcm音頻數(shù)據(jù)
val buffer = ByteArray(bufferSize)
audioRecord!!.startRecording()
// 在IO線程中采集pcm音頻數(shù)據(jù)
GlobalScope.launch(Dispatchers.IO) {
mScope = this
var fileOutputStream: FileOutputStream? = null
try {
fileOutputStream = FileOutputStream(pcmFile)
while (isActive) {
val readStatus = audioRecord!!.read(buffer, 0, bufferSize)
Log.d(TAG, "scope: readStatus = $readStatus")
fileOutputStream.write(buffer)
}
} catch (exception: IOException) {
Log.d(TAG, "scope: exception = $exception")
} finally {
fileOutputStream?.also {
try {
it.close()
} catch (exception: IOException) {
}
}
}
}
}
/**
* 暫停音頻錄制
*
* @author cytmxk
* @since 2021/10/12
*/
public fun stopRecord() {
mScope ?: return
mScope!!.cancel()
mScope = null
if (AudioRecord.STATE_UNINITIALIZED != audioRecord!!.state) {
audioRecord!!.stop()
// 調(diào)用release方法之后該對象不可以再次被使用,因此必須將該對象置null
audioRecord!!.release()
audioRecord = null
}
}
上面的代碼都有注釋,就不在這里詳細(xì)講解了督勺,執(zhí)行完成之后會生成一個(gè)用于保存PCM音頻數(shù)據(jù)的test.pcm文件渠羞。
為了讓手機(jī)上的播放器可以播放采集的PCM音頻數(shù)據(jù),那么接下來通過下面的代碼就可將test.pcm文件中保存的PCM音頻數(shù)據(jù)轉(zhuǎn)換成WAVE文件格式并且保存到convert.wav文件中:
private fun convertPcmToWav() {
val wavFile = MediaFileUtils.getAudioFile("convert.wav")
wavFile ?: return
if (wavFile.exists()) {
wavFile.delete()
}
var fileInputStream: FileInputStream? = null
var fileOutputStream: FileOutputStream? = null
try {
fileInputStream = FileInputStream(pcmFile)
fileOutputStream = FileOutputStream(wavFile)
val audioByteLen = fileInputStream.channel.size()
val wavByteLen = audioByteLen + 36
addWavHeader(fileOutputStream, audioByteLen, wavByteLen)
val buffer = ByteArray(bufferSize)
while (fileInputStream.read(buffer) != -1) {
fileOutputStream.write(buffer)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
try {
fileInputStream?.close()
fileOutputStream?.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
}
private fun addWavHeader(
fileOutputStream: FileOutputStream, audioByteLen: Long, wavByteLen: Long
) {
val header = ByteArray(44)
// WAVE chunk
// header[0] ~ header[3] 內(nèi)容為"RIFF"
header[0] = 'R'.code.toByte()
header[1] = 'I'.code.toByte()
header[2] = 'F'.code.toByte()
header[3] = 'F'.code.toByte()
// header[4] ~ header[7] 存儲文件的字節(jié)數(shù)(不包含ChunkID和ChunkSize這8個(gè)字節(jié))
header[4] = (wavByteLen and 0xff).toByte()
header[5] = (wavByteLen shr 8 and 0xff).toByte()
header[6] = (wavByteLen shr 16 and 0xff).toByte()
header[7] = (wavByteLen shr 24 and 0xff).toByte()
// header[8] ~ header[11] 內(nèi)容為"WAVE"
header[8] = 'W'.code.toByte()
header[9] = 'A'.code.toByte()
header[10] = 'V'.code.toByte()
header[11] = 'E'.code.toByte()
// "fmt " 子chunk 4個(gè)字節(jié)
// header[12] ~ header[15] 內(nèi)容為 "fmt "
header[12] = 'f'.code.toByte()
header[13] = 'm'.code.toByte()
header[14] = 't'.code.toByte()
header[15] = ' '.code.toByte()
// header[16] ~ header[19] 存儲該子塊的字節(jié)數(shù)(不包含Subchunk1ID和Subchunk1Size這8個(gè)字節(jié))
header[16] = 16
header[17] = 0
header[18] = 0
header[19] = 0
// header[20] ~ header[21] 存儲音頻文件的編碼格式智哀,例如若為PCM則其存儲值為1次询,若為其他非PCM格式的則有一定的壓縮。
header[20] = 1
header[21] = 0
// header[22] ~ header[23] 通道數(shù)瓷叫,單通道(CHANNEL_IN_MONO)值為1屯吊,雙通道(CHANNEL_IN_STEREO)值為2
val channelSize = if (channelConfig == AudioFormat.CHANNEL_IN_MONO) 1 else 2
header[22] = channelSize.toByte()
header[23] = 0
// header[24] ~ header[27] 采樣頻率
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()
// header[28] ~ header[31] 每秒采集的音頻字節(jié)數(shù)
val byteRate = (audioFormat * sampleRateInHz * channelSize).toLong()
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()
// header[32] ~ header[33] 塊對齊大小,每個(gè)采樣(包含所有聲道)需要的字節(jié)數(shù)
header[32] = (channelSize * bitsPerSample / 8).toByte()
header[33] = 0
// header[34] ~ header[35] 每個(gè)采樣需要的 bit 數(shù)
header[34] = bitsPerSample.toByte()
header[35] = 0
//data 子chunk
// header[36] ~ header[39] 內(nèi)容為“data”
header[36] = 'd'.code.toByte()
header[37] = 'a'.code.toByte()
header[38] = 't'.code.toByte()
header[39] = 'a'.code.toByte()
// header[40] ~ header[43] pcm字節(jié)數(shù)
header[40] = (audioByteLen and 0xff).toByte()
header[41] = (audioByteLen shr 8 and 0xff).toByte()
header[42] = (audioByteLen shr 16 and 0xff).toByte()
header[43] = (audioByteLen shr 24 and 0xff).toByte()
try {
fileOutputStream.write(header, 0, 44)
} catch (e: IOException) {
e.printStackTrace()
}
}
通過系統(tǒng)文件夾應(yīng)用找到convert.wav文件的位置摹菠,點(diǎn)擊就可以播放了盒卸。
其實(shí)PCM音頻數(shù)據(jù)也可以直接使用AudioTrack播放,實(shí)現(xiàn)代碼如下:
private var audioTrack: AudioTrack? = null
private const val sampleRateInHz: Int = 44100
private const val channelConfig = AudioFormat.CHANNEL_OUT_MONO // 錯(cuò)誤的寫成了CHANNEL_IN_MONO
private const val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val bufferSize =
AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
private var pcmFile: File? = null
private var mScope: CoroutineScope? = null
public fun playAudioByAudioTrack() {
pcmFile = MediaFileUtils.getAudioFile("test.pcm")
Log.d(TAG, "initPCMFile: pcmFile=${pcmFile}")
pcmFile ?: return
if (!pcmFile!!.exists()) {
return
}
stopPlayAudio()
initAudioTrackWithMode(AudioTrack.MODE_STREAM)
if (audioTrack!!.state == AudioTrack.STATE_UNINITIALIZED) {
Log.e(TAG, "state is uninit")
return
}
// 在IO線程中播放采集的pcm音頻數(shù)據(jù)
GlobalScope.launch(Dispatchers.IO) {
mScope = this
var fileInputStream: FileInputStream? = null
try {
fileInputStream = FileInputStream(pcmFile)
val buffer = ByteArray(bufferSize / 2)
//stream模式次氨,可以先調(diào)用play
audioTrack!!.play()
while (isActive && fileInputStream.available() > 0) {
val readCount = fileInputStream.read(buffer)
if (readCount == AudioTrack.ERROR_BAD_VALUE || readCount == AudioTrack.ERROR_INVALID_OPERATION) {
continue
}
if (readCount > 0 && audioTrack!!.playState == AudioTrack.PLAYSTATE_PLAYING && audioTrack!!.state == AudioTrack.STATE_INITIALIZED) {
audioTrack!!.write(buffer, 0, readCount)
}
}
} catch (exception: IOException) {
Log.d(TAG, "scope: exception = $exception")
} finally {
fileInputStream?.also {
try {
it.close()
} catch (exception: IOException) {
}
}
}
}
}
private fun initAudioTrackWithMode(mode: Int) {
audioTrack = AudioTrack(
AudioAttributes.Builder()
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
.build(),
AudioFormat.Builder()
.setChannelMask(channelConfig)
.setEncoding(audioFormat)
.setSampleRate(sampleRateInHz)
.build(),
bufferSize,
mode, AudioManager.AUDIO_SESSION_ID_GENERATE
)
}
public fun stopPlayAudio() {
mScope ?: return
mScope!!.cancel()
if (AudioTrack.STATE_UNINITIALIZED != audioTrack!!.state) {
audioTrack!!.stop()
audioTrack!!.release()
}
}
通過執(zhí)行上面playAudioByAudioTrack方法就可以播放上面生成的test.pcm音頻文件蔽介。