最近有個需求:對音頻裁剪時,裁剪條的縱座標必須是音頻音量,以幫助用戶更好的選擇音頻區域,因此就須要快速準確的提取出音頻的音量列表。本文主要介紹下從mp4文件中提取音軌音量的方式,以及相關的知識點。html
聲音的本質是空氣壓力差形成的空氣振動,振動產生的聲波能夠在介質中快速傳播,當聲波到達接收端時(好比:人耳、話筒),引發相應的振動,最終被聽到。 java
聲音有兩個基本屬性:頻率與振幅。聲音的振幅就是音量,頻率的高低就是音調,頻率的單位是赫茲(Hz)。shell
當聲波傳遞到話筒時,話筒裏的碳膜會隨着聲音一塊兒振動,而碳膜下面是一個電極,碳膜振動時會觸碰電極,接觸時間的長短跟振動幅度有關(即:聲音響度),這樣就完成了聲音信號到電壓信號的轉換。後面通過電路放大後,就獲得了模擬音頻信號。框架
模擬音頻:用連續的電流或電壓表示的音頻信號,在時間和振幅上是連續。過去記錄的聲音都是模擬音頻,好比:機械錄音(以留聲機、機械唱片爲表明)、磁性錄音(以磁帶錄音爲表明)等模擬錄音方式。函數
計算機不能直接處理連續的模擬信號,因此須要進行A/D轉換,以必定的頻率對模擬信號進行採樣(就是獲取必定時間間隔的波形振幅值,採樣後模擬出的波形與原始波形之間的偏差稱爲採樣噪音),而後再進行量化和存儲,就獲得了數字音頻。測試
數字音頻:經過採樣和量化得到的離散的、數字化的音頻信號,即:計算機能夠處理的二進制的音頻數據。編碼
相反的,當經過揚聲器播放聲音時,計算機內部的數字信號經過D/A轉換,還原成了強弱不一樣的電壓信號。這種強弱變化的電壓會推進揚聲器的振動單元產生震動,就產生了聲音。整個流程能夠用下圖來表示: spa
最多見的A/D
轉換是經過脈衝編碼調製PCM(Pulse Code Modulation)。要將連續的電壓信號轉換爲PCM,須要進行採樣和量化,咱們通常從以下幾個維度描述PCM:.net
數字音頻文件大小(Byte) = 採樣頻率(Hz)× 採樣時長(S)×(採樣位數 / 8)× 聲道數(單聲道爲1,立體聲爲2)
複製代碼
採樣點數據有有符號和無符號之分,好比:8 bit的樣本數據,有符號的範圍是-128 ~ 127,無符號的範圍是0 ~ 255。大多數PCM樣本使用整形表示,可是在一些對精度要求比較高的場景,可使用浮點類型表示PCM樣本數據。3d
下面看一個具體的採樣示例:
其中,黑色曲線表示要採集的聲音波形,紅色曲線表示採樣量化後的PCM數據波形。 上圖中,採樣位數是4 bit,每一個紅點對應一個Pcm採樣數據,很明顯:
接下來看下PCM數據存儲方式,若是是單聲道音頻,採樣數據按照時間的前後順序依次存儲,若是是雙聲道音頻,則按照LRLRLR
方式存儲,每一個採樣點的存儲方式還與機器大小端有關。大端模式以下圖所示:
Pcm文件沒有頭部信息,所有是採樣量化後的未壓縮音頻數據。
咱們通常用分貝(db)描述聲音響度。聲學領域中,分貝的定義是聲源功率與基準聲源功率比值的對數乘以20的數值。根據人耳的特性,咱們對聲音的大小感知呈對數關係,而不是線性關係。人類的聽覺反應是基於聲音的相對變化而非絕對變化。對數函數正好能模仿人耳對聲音的反應。因此用分貝描述聲音強度更符合人類對聲音強度的感知。以下圖所示,橫軸表示PCM採樣值,縱軸表示人耳感知到的音量,圖中截取了兩塊橫軸變化相同的區域,可是人耳感受到的音量變化是不同的。在較安靜的左側,感受到的音量變化較大;在叫喧囂的右側,人耳感受到的音量變化較小。
具體來講,分貝計算公式以下所示:
其中,表示兩個採樣值的比值。在計算某個採樣值的分貝時,直接把當成最小採樣值1處理就能夠了。因此若是採樣位數是16 bit,那麼無符號狀況下,最大分貝是:
有符號狀況下,最大分貝是:
OK,瞭解了PCM格式和db計算方式以後,咱們看下從音頻文件提取db值的總體流程:
首先,咱們基於Android平臺的多媒體API來實現PCM的數據提取,而後計算分貝值。 簡單概述就是:首先經過MediaExtractor
解封裝Mp4提取AAC編碼流,而後經過MediaCodec
解碼AAC數據,獲得PCM。核心代碼以下所示:
// 解封裝器
val audioExtractor = MediaExtractor()
// 設置路徑
audioExtractor.setDataSource(audioInputPath)
// 找到音軌
for (i in 0 until audioExtractor.trackCount) {
val format = audioExtractor.getTrackFormat(i)
if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
audioExtractor.selectTrack(i)
// 音軌Format
inputAudioFormat = format
break
}
}
// 音頻聲道數
audioChannel = inputAudioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
// 音頻採樣率
audioSampleRate = inputAudioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val mime = inputAudioFormat.getString(MediaFormat.KEY_MIME)
val sampleBitStr = inputAudioFormat.getString(MediaFormat.KEY_PCM_ENCODING)
val sampleBit = if (sampleBitStr != null) {
try {
Integer.parseInt(sampleBitStr)
} catch (e: Exception) {
AudioFormat.ENCODING_PCM_16BIT
}
} else {
AudioFormat.ENCODING_PCM_16BIT
}
// 一個採樣點佔用的字節數
sampleByte = when (sampleBit) {
AudioFormat.ENCODING_PCM_8BIT -> 1
AudioFormat.ENCODING_PCM_16BIT -> 2
else -> 2
}
// 啓動解碼器
val audioDecoder = MediaCodec.createDecoderByType(mime)
audioDecoder.configure(inputAudioFormat, null, null, 0)
audioDecoder.start()
// 解碼器的輸入和輸出Buffer列表
val decoderInputBuffer = audioDecoder.inputBuffers
var decoderOutputBuffer = audioDecoder.outputBuffers
val bufferInfo = MediaCodec.BufferInfo()
while (!decodeDone) {
if (!inputDone) { // 提取AAC,進行編碼
val inputIndex = audioDecoder.dequeueInputBuffer(0L)
if (inputIndex >= 0) {
val inputBuffer = decoderInputBuffer[inputIndex]
inputBuffer.clear()
val readSampleSize = localAudioExtractor.readSampleData(inputBuffer, 0)
if (readSampleSize > 0) {
audioDecoder.queueInputBuffer(inputIndex, 0, readSampleSize, localAudioExtractor.sampleTime, localAudioExtractor.sampleFlags)
// 移動到下一幀
audioDecoder.advance()
} else { // 結束幀
audioDecoder.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
}
}
}
if (!decodeDone) {
val outputIndex = localAudioDecoder.dequeueOutputBuffer(bufferInfo, 0)
if (outputIndex >= 0) {
if(bufferInfo.size > 0){
val outputBuffer = decoderOutputBuffer[outputIndex]
// 大小端
val isBigEndian = (outputBuffer.order() == ByteOrder.BIG_ENDIAN)
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
val pcmByteArray = ByteArray(bufferInfo.size)
// copy出PCM數據
outputBuffer.get(pcmByteArray)
outputBuffer.clear()
// 當前幀採樣點個數
val curSampleNum = pcmByteArray.size / sampleByte / audioChannel
// 計算出當前幀的DB值
val db = compute(isBigEndian,pcmByteArray,audioChannel,sampleByte)
// 處理db值
......
}
// 歸還Buffer
audioDecoder.releaseOutputBuffer(outputIndex, false)
// 判斷是不是最後的幀
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0){
decodeDone = true
}
}
}
}
複製代碼
上述代碼是經過MediaExtractor
和MediaCodec
解碼音視頻的標準流程,已經添加了詳細的註釋,咱們看下基於PCM計算db的具體函數:
fun compute(isBigEndian : Boolean ,pcmByteArray : ByteArray,audioChannel : Int,sampleByte : Int){
// 計算出步長:MediaCodec解碼出的PCM數據是按照Packed模式存儲的
val step = if (audioChannel == 2) {
if (sampleByte == 2) {
4
} else {
2
}
} else {
if (sampleByte == 2) {
2
} else {
1
}
}
var i = 0
var sum = 0.0
while (i < pcmByteArray.size) {
// 絕對值求和
sum += if (sampleByte == 2) {
// 根據大小端把兩個byte轉換成short
val sample = byteToShort(isBigEndian, pcmArray[i], pcmArray[i + 1])
Math.abs(sample.toInt()).toDouble()
} else {
Math.abs(pcmByteArray[i].toInt()).toDouble()
}
i += step
}
// 基於平均採樣點,計算出db值
return (20 * log10(sum / (pcmByteArray.size / step))).toInt()
}
複製代碼
經過上述代碼,咱們能夠基於解碼出的PCM,計算出對應的db值,可是這種方式存在一個最大的缺點就是耗時嚴重,一個5分鐘的音頻,須要二三十秒,甚至更長,這徹底是沒法忍受的。咱們不得不尋求更高效的解決方案。
IOS平臺提供了AVFoundation
庫,用於音視頻操做。咱們能夠基於它直接提取出整首歌的PCM數據,而後計算出分貝值。大致流程以下所示:
AVAudioFile
加載本地音頻文件,獲取採樣率、聲道數等音頻信息。AVAudioCommonFormat
構建AVAudioFormat
,表示一種音頻格式。AVAudioFormat
和音頻採樣幀數(等於採樣率乘以時長)構建AVAudioPCMBuffer
,而且經過AVAudioFile.read
把音頻數據解碼到AVAudioPCMBuffer
,獲取到解碼後的PCM Buffer。AVAudioPCMBuffer
包含了多個聲道的數據,多個聲道的數據是如何存儲的那?能夠經過AVAudioFormat.isInterleaved
進行判斷,如果true,則表示多個聲道數據是交替存儲的,即:LRLRLRLR
方式,如果false,則表示多個聲道數據是分開存儲的,即:LLLLRRRR
模式。AVAudioPCMBuffer
提供的PCM數據,針對單一聲道,計算出分貝值,計算方式與Android平臺相似,此處再也不贅述。可見,iOS平臺對音頻數據的提取提供了很是友好的API,而且測試下來發現,同一首5分鐘的歌曲,耗時只有兩三秒,各個方面,都吊打Android。
除了Android和iOS平臺的多媒體框架,咱們還能夠基於FFmpeg實現跨平臺的PCM數據提取。FFmpeg是一個開源的跨平臺多媒體框架,關於FFmpeg的介紹,網上的資料不少,這裏就再也不贅述了。
經過FFmpeg解碼本地音視頻文件,仍是比較簡單的,總體流程以下所示:
AVPacket
,而後交給解碼器解碼,最後從解碼器獲取PCM原始數據幀AVFrame
(av_packet_alloc -> avcodec_receive_frame)。AV_SAMPLE_FMT_S16P
格式(swr_convert)。不一樣於MediaCodec
解碼出的PCM是按照LRLRLR
方式存儲,FFmpeg解碼出的PCM存儲格式更加豐富,以下所示:
enum AVSampleFormat {
AV_SAMPLE_FMT_NONE = -1,
AV_SAMPLE_FMT_U8, ///< unsigned 8 bits
AV_SAMPLE_FMT_S16, ///< signed 16 bits
AV_SAMPLE_FMT_S32, ///< signed 32 bits
AV_SAMPLE_FMT_FLT, ///< float
AV_SAMPLE_FMT_DBL, ///< double
AV_SAMPLE_FMT_U8P, ///< unsigned 8 bits, planar
AV_SAMPLE_FMT_S16P, ///< signed 16 bits, planar
AV_SAMPLE_FMT_S32P, ///< signed 32 bits, planar
AV_SAMPLE_FMT_FLTP, ///< float, planar
AV_SAMPLE_FMT_DBLP, ///< double, planar
AV_SAMPLE_FMT_S64, ///< signed 64 bits
AV_SAMPLE_FMT_S64P, ///< signed 64 bits, planar
AV_SAMPLE_FMT_NB ///< Number of sample formats. DO NOT USE if linking dynamically
};
複製代碼
除了有有符號和無符號的區別外,還能夠是short、float和double類型,採樣位數也能夠是8 bit、16 bit、32 bit和64 bit。除此以外,即便一樣是signed 16 bits,也存在Packed
和Planar
的區別。
對於雙聲道音頻來講,Packed
表示兩個聲道的數據交錯存儲,交織在一塊兒,即:LRLRLRLR
的存儲方式;Planar
表示兩個聲道分開存儲,也就是平鋪分開,即:LLLLRRRR
的存儲方式。經過MediaCodec
解碼出的PCM是按照Packed
方式存儲的,而FFmpeg解碼出的PCM則多是其中的任意一種。
因此爲了更好的歸一化處理,咱們會對FFmpeg解碼出的PCM進行重採樣,統一採樣成AV_SAMPLE_FMT_S16P
格式,即:每一個採樣點是兩字節的有符號short類型,而且按照Planar
方式存儲。
重採樣:對PCM數據進行從新採樣,能夠改變它的聲道數、採樣率和採樣格式。 好比:原先的PCM音頻數據是2個聲道,44100採樣率,32 bit單精度型。那麼能夠重採樣成:2個聲道,44100採樣率,有符號short類型。
關於分貝值的計算,與上述基於Android平臺的計算方式基本一致,此處就再也不贅述了。
同一首5分鐘的歌,經過FFmpeg提取PCM的耗時只有一兩秒,提取效率至少提高了10倍以上,基本上與iOS持平,至此終於能夠鬆一口氣了。
PCM是原始採樣數據,必須指定採樣率、聲道數和採樣位數(大小端)才能播放。 經過ffplay
播放PCM的命令以下所示:
fplay -ar 44100 -channels 2 -f s16le -i test.pcm
參數說明:
1. -ar PCM採樣率
2. -channels PCM通道數
3. -f PCM格式:sample_fmts + le(小端)或者be(大端)
sample_fmts能夠經過ffplay -sample_fmts來查詢
複製代碼
除此以外,經過Audacity也能夠直接播放PCM數據:文件 -> 導入 -> 原始數據,而後選擇對應的採樣率、聲道數、採樣位數和大小端就能夠播放了。
Audacity功能很強大,對於PCM的波形(採樣點值)、響度(db)和頻譜,均可以直接查看,以下所示: PCM-波形
PCM-響度
PCM-頻譜
爲何Android平臺解封裝、解碼音頻提取PCM的速度這麼慢? 具體緣由我也沒法猜想,待深刻研究以後再來解答吧,若是音視頻的大佬有相關經驗,也麻煩告知。