什么是 PCM 格式
聲音從模擬信號轉(zhuǎn)化為數(shù)字信號的技術(shù),經(jīng)過采樣、量化、編碼三個(gè)過程將模擬信號數(shù)字化。
-
采樣
顧名思義,對模擬信號采集樣本,該過程是從時(shí)間上對信號進(jìn)行數(shù)字化,例如每秒采集 44100 次,即采樣頻率 44.1 khz
-
量化
既然是將音頻數(shù)字化,那就需要使用二進(jìn)制來表示聲音的每一個(gè)樣本。例如每個(gè)樣本使用 16 位長度來表示,即音頻的位深度為 16 位
-
編碼
編碼就是按照一定的格式記錄采樣和量化后的數(shù)據(jù),比如順序存儲或壓縮存儲等
編碼后經(jīng)由不同的算法,音頻被保存為不同的格式,例如 MP3、AAC 等,而 PCM 就是最為原始的一種格式,PCM 數(shù)據(jù)是音頻的裸數(shù)據(jù)格式,不經(jīng)過任何壓縮。
從零到一:使用 AudioTrack 支持 PCM 格式音頻的播放
AudioTrack 只支持播放 PCM 編碼格式的音頻流,平時(shí)使用的 MediaPlayer 支持 MP3、AAC 等多種音頻格式,其內(nèi)部也是將 MP3 格式文件使用 framework 層創(chuàng)建的解碼器解碼為 PCM 裸數(shù)據(jù),再經(jīng)由 AudioTrack 播放的。封裝過的 Mediaplayer 的 API 是簡單好用,但許多細(xì)節(jié)我們卻無法掌控,而使用 AudioTrack,除了播放之外,我們還可以對數(shù)據(jù)源做許多有意思的操作,二者各有優(yōu)劣之處。
先通過構(gòu)造函數(shù)來了解 AudioTrack(版本為API26):
public AudioTrack(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes, int mode, int sessionId) {
...
}
幾個(gè)參數(shù)介紹一下:
-
AudioAttributes
定義音頻的類型,包括音樂、通知、鬧鐘等,調(diào)節(jié)音量時(shí)也會根據(jù)不同的類型進(jìn)行調(diào)節(jié)
-
AudioFormat
定義音頻的格式,可配置聲道數(shù)(單通道、多通道)、編碼格式(每個(gè)采樣數(shù)據(jù)位深度,8bit、16bit等)、采樣率
-
bufferSizeInBytes
AudioTrack 內(nèi)部的音頻緩沖區(qū)的大小,該緩沖區(qū)的值不能低于一幀“音頻幀”(Frame)的大小,即:采樣率 x 位深 x 采樣時(shí)間 x 通道數(shù),采樣時(shí)間一般取 2.5ms~120ms 之間,AudioTrack 類提供了
getMinBufferSize()
方法來計(jì)算該值 -
mode
AudioTrack 的兩種播放模式,MODE_STATIC 和 MODE_STREAM,前者直接將數(shù)據(jù)加載進(jìn)內(nèi)存,后者是按照一定的間隔不斷地寫入數(shù)據(jù)
API26 下原有的兩個(gè)構(gòu)造函數(shù)已經(jīng)被標(biāo)為廢棄,建議使用 Builder 來構(gòu)造 AudioTrack 對象:
private fun createAudioTrack(): AudioTrack {
val format = AudioFormat.Builder()
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setSampleRate(44100)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
return AudioTrack.Builder()
.setAudioFormat(format)
.build()
}
網(wǎng)上介紹 AudioTrack 時(shí)常見的如何構(gòu)造 bufferSizeInBytes
也不用關(guān)心了,Builder 類會替我們默認(rèn)生成。
構(gòu)造出對象后,在調(diào)用 play()
函數(shù)開啟播放后,只要開啟一個(gè)線程不斷地從源文件中讀取數(shù)據(jù)并調(diào)用 AudioTrack 的 write()
函數(shù)向手機(jī)端音頻輸出設(shè)備傳輸數(shù)據(jù),即可播放 PCM 音頻。
使用 ffmpeg 將 MP3 轉(zhuǎn)為 PCM
Mac 下安裝 ffmpeg:brew install ffmpeg
使用 ffmpeg 將 mp3 轉(zhuǎn)換為 pcm:ffmpeg -i xxx.mp3 -f s16le -ar 44100 -ac 2 -acodec pcm_s16le xxx.pcm
- -i 制定輸入文件
- -f 指定輸出編碼格式為16byte小端格式
- -ar 指定輸出采樣率
- -ac 指定輸出通道數(shù)
- acodec 指定解碼格式
- xxx.pcm 為輸出文件
這里也提供一下我使用的 PCM 文件:hurt-johnny cash.pcm 44.1khz 雙通道 16位深
PCM 錄制:AudioRecord
MediaRecorder 錄制集成了編碼、壓縮等功能,AudioRecord 錄制的是 PCM 格式的音頻文件。
同樣的,先從構(gòu)造函數(shù)來認(rèn)識 AudioRecord:
public AudioRecord(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes, int sessionId) {
...
}
接觸過 AudioTrack,對 AudioRecord 一定不會感到陌生,其構(gòu)造函數(shù)參數(shù)與 AudioTrack 幾乎如出一轍,這里就不多說了。
AudioRecord 的 API 與 AudioTrack 也是遙相呼應(yīng)的,在調(diào)用函數(shù) startRecording()
開啟錄制后,只要開啟一個(gè)后臺線程不斷地調(diào)用 read()
函數(shù)從手機(jī)端的音頻輸入設(shè)備(麥克風(fēng)等)讀取音頻數(shù)據(jù),并寫入本地文件,即可實(shí)現(xiàn)音頻的錄制。
擴(kuò)展支持的音頻格式: WAV
最開始提到過音頻會被編碼成不同的格式,而常見的壓縮編碼格式 WAV 格式可能是與 PCM 數(shù)據(jù)最為接近的一種格式。WAV 編碼不會進(jìn)行壓縮操作,它只在 PCM 數(shù)據(jù)格式前加上 44 字節(jié)(并不一定嚴(yán)格是 44 字節(jié))來描述音頻的基本信息,例如采樣率、聲道數(shù)、數(shù)據(jù)格式等。來看看 WAV 文件頭的格式:
WAV 文件頭格式
長度(字節(jié)) | 內(nèi)容 |
---|---|
4 | "RIFF" 字符串 |
4 | 從下個(gè)地址開始到文件尾的總字節(jié)數(shù)(音頻 data 數(shù)據(jù)長度 + 44 -8) |
4 | "WAVE" |
4 | "fmt "(最后有一個(gè)空格) |
4 | 過渡字節(jié)(一般為00000010H),若為00000012H則說明數(shù)據(jù)頭攜帶附加信息(見“附加信息”) |
2 | 格式種類,1 表示為PCM形式的聲音數(shù)據(jù) |
2 | 通道數(shù),單聲道為1,雙聲道為2 |
4 | 采樣率 |
4 | 波形音頻數(shù)據(jù)傳送速率,其值為通道數(shù)×每秒數(shù)據(jù)位數(shù)×每樣本的數(shù)據(jù)位數(shù)/8。播放軟件利用此值可以估計(jì)緩沖區(qū)的大小。 |
2 | 每個(gè)采樣需要的字節(jié)數(shù),其值為通道數(shù)×位深度/8。播放軟件需要一次處理多個(gè)該值大小的字節(jié)數(shù)據(jù),以便將其值用于緩沖區(qū)的調(diào)整。 |
2 | 位深度 |
4 | "data" |
4 | DATA數(shù)據(jù)長度 |
了解了 WAV 文件頭的格式,我們可以嘗試自己寫一個(gè)解析 WAV 文件頭的方法,結(jié)合上文的 AudioTrack 播放 PCM 的內(nèi)容來看,只要獲取到音頻的采樣率、位深度與聲道數(shù)就可以播放該音頻。自然也就可以播放內(nèi)容是 PCM 格式的 WAV 文件。
在此之前需要一個(gè) WAV 文件用作測試,可以使用 ffmpeg 將之前轉(zhuǎn)換的 PCM 格式音頻轉(zhuǎn)碼成 WAV 格式。
使用 ffmpeg 將 PCM 轉(zhuǎn)為 WAV
ffmpeg -i xxx.pcm -f s16le -ar 44100 -ac 2 xxx.wav
也分享一下我使用的 WAV 文件:hurt-johnny cash.wav 44.1khz 雙通道 16位深
解析 WAV 文件頭
根據(jù)上面提到的 WAV 文件頭格式,定義一個(gè)類用于存放文件頭數(shù)據(jù):
public class WaveHeader {
private String riff; // "RIFF"
private int totalLength; //音頻 data 數(shù)據(jù)長度 + 44 -8
private String wave; // "WAVE"
private String fmt; // "fmt "
private int transition; //過渡字節(jié),一般為0x00000010
private short type; // PCM:1
private short channelMask; // 單聲道:1,雙聲道:2
private int sampleRate; //采樣率
private int rate; // 波形音頻數(shù)據(jù)傳送速率,其值為通道數(shù)×每秒數(shù)據(jù)位數(shù)×每樣本的數(shù)據(jù)位數(shù)/8
private short sampleLength; // 每個(gè)采樣需要的字節(jié)數(shù),其值為通道數(shù)×位深度/8
private short deepness; //位深度
private String data; // "data"
private int dataLength; //data數(shù)據(jù)長度
...
}
定義好文件頭后,我們使用 BufferedInputStream
從本地文件輸入流中挨個(gè)字節(jié)讀取數(shù)據(jù)即可。
byte 字節(jié)轉(zhuǎn)字符串
private static String readString(InputStream inputStream, int length) {
byte[] bytes = new byte[length];
try {
inputStream.read(bytes);
return new String(bytes);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
byte 字節(jié)轉(zhuǎn) short、int
需要注意的是 WAV 頭中的字節(jié)數(shù)組是經(jīng)過反轉(zhuǎn)的,例如表示單通道的字節(jié)數(shù)組為{1, 0},其中 1 為低位字節(jié),即原始的字節(jié)為 [0, 1],轉(zhuǎn)換為二進(jìn)制為 0000 0000 0000 0001
,即十進(jìn)制的 1,代表單通道。
//從輸入流中讀取 2 個(gè)字節(jié)并轉(zhuǎn)換為 short
private static short readShort(InputStream inputStream) {
byte[] bytes = new byte[2];
try {
inputStream.read(bytes);
//{1, 0}
return (short) ((bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8));
} catch (IOException e) {
e.printStackTrace();
return 0;
}
}
//從輸入流中讀取 4 個(gè)字節(jié)并轉(zhuǎn)換為 int
private static int readInt(InputStream inputStream) {
byte[] bytes = new byte[4];
try {
inputStream.read(bytes);
return (int) ((bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8) | ((bytes[2] & 0xff) << 16) | ((bytes[3] & 0xff) << 24));
} catch (IOException e) {
e.printStackTrace();
return 0;
}
}
從代碼中可以看到,字節(jié)轉(zhuǎn)換為整型數(shù)據(jù)類型(short、int 等)時(shí),需要先與 0xff
做與(&
)操作,這是為什么呢?
類型轉(zhuǎn)換時(shí) byte & 0xff 的原因
要究其原因,首先需要搞清楚計(jì)算機(jī)中的原碼、反碼和補(bǔ)碼,這個(gè)在之前的文章《Bitmap 圖像灰度變換原理淺析》中計(jì)算 int 數(shù)據(jù)類型的值范圍時(shí)也提到過。下面舉個(gè)栗子來驗(yàn)證一下,類型轉(zhuǎn)換前 & 0xff
到底有什么用:
假定有一個(gè) byte 數(shù)組:[0, 0, -1, 0],想求得這 4 個(gè)字節(jié)代表的 int 值得大小,重點(diǎn)看第三個(gè)字節(jié),其值為 -1,byte 占一個(gè)字節(jié) 8 位,易得:
- 原碼:
1000 0001
- 反碼:
1111 1110
- 補(bǔ)碼:
1111 1111
顯而易見,這個(gè) byte 數(shù)組代表的 int 值的二進(jìn)制值為:
0000 0000 0000 0000 1000 0001 0000 0000
只要對 -1 左移 8 位即可。so easy!
byte a = -1;
int b = a << 8; //結(jié)果是 -256
值為負(fù)數(shù)就說明以上轉(zhuǎn)換必然是錯(cuò)的。因?yàn)槲覀兿胍慕Y(jié)果是 0000 0000 0000 0000 1000 0001 0000 0000
,該二進(jìn)制最高位符號位為 0,結(jié)果必然是一個(gè)正數(shù)。
我們知道計(jì)算機(jī)在運(yùn)算時(shí)使用的是補(bǔ)碼,則 (byte)-1 << 8 運(yùn)算時(shí),計(jì)算機(jī)會對 -1 的補(bǔ)碼 1111 1111
做位移操作,結(jié)果為 1111 1111 0000 0000
,其原碼為 1000 0001 0000 0000
,先記作 16位原碼A,當(dāng)該值賦值給 int 類型的變量時(shí),int 類型占 4 個(gè)字節(jié) 32 位,則需要對原因的 16 位值做位擴(kuò)展,負(fù)數(shù)在位擴(kuò)展時(shí)會對多出的高位補(bǔ) 1(正數(shù)補(bǔ) 0),則擴(kuò)展后的值為 1111 1111 1111 1111 1111 1111 0000 0000
,轉(zhuǎn)為原碼為 1000 0000 0000 0000 0000 0001 0000 0000
,記作 32位原碼B,與上面的 16 位原碼做比較可以發(fā)現(xiàn),二者最高位都是 1,低位的值也相同,兩個(gè)二進(jìn)制的值在十進(jìn)制上是一致的,這就是負(fù)數(shù)補(bǔ)碼高位補(bǔ) 1 的原因:為了保持十進(jìn)制的一致性。不難理解這樣做的原因,否則 short 類型強(qiáng)制為 int 類型后值就發(fā)生變化了,這明顯是不可接受的。
回到我們的需求,我們這里并不需要保持十進(jìn)制的一致性,所以要先與 0xff 做與運(yùn)算,因?yàn)?0xff 是十六進(jìn)制 32 位,二進(jìn)制值為 32 個(gè) 1,-1 & 0xff
時(shí),8 位與 32 位運(yùn)算時(shí),8 位的需要先在高位補(bǔ) 0 補(bǔ)齊到 32 位才會做運(yùn)算,所以
-1 & 0xff
的結(jié)果為補(bǔ)碼:1111 1111
前面帶 24 個(gè) 0,最高位為正數(shù),再對結(jié)果做位移操作,得到的二進(jìn)制值補(bǔ)碼為:0000 0000 0000 0000 1111 1111 0000 0000
,因?yàn)槭钦龜?shù),原碼與補(bǔ)碼相同,該二進(jìn)制值為 65280。可以驗(yàn)證下:
byte a = -1;
int c = (a & 0xff) << 8; //結(jié)果是 65280
概括一下就是:
類型轉(zhuǎn)換時(shí)補(bǔ)碼位擴(kuò)展(例如 2 個(gè)字節(jié)轉(zhuǎn) 4 個(gè)字節(jié),即 short 轉(zhuǎn) int)的規(guī)則:正數(shù)高位補(bǔ) 0,負(fù)數(shù)高位補(bǔ)1,以此保持十進(jìn)制的一致性
運(yùn)算時(shí),補(bǔ)碼高位統(tǒng)一補(bǔ) 0
這里也附上將 byte 轉(zhuǎn)二進(jìn)制(補(bǔ)碼)的方法:
public static String binary(byte bytes, int radix){
byte[] bytes1 = new byte[1];
bytes1[0] = bytes;
return new BigInteger(1, bytes1).toString(radix);// 這里的1代表正數(shù)
}
順序讀取,構(gòu)造 WavHeader
接著只要使用 InputStream 從目標(biāo)音頻中順序讀取各個(gè)參數(shù)的值并構(gòu)造 WavHeader 即可,因?yàn)?Header 成員變量眾多,所以考慮用建造者模式來構(gòu)建 Header:
WaveHeader.Builder builder = new WaveHeader.Builder()
.setRiff(readString(dis, 4))
.setTotalLength(readInt(dis))
.setWave(readString(dis, 4))
.setFmt(readString(dis, 4))
...
另外上面提到過,并非所以 WAV 文件頭都是標(biāo)準(zhǔn)的 44 個(gè)字節(jié),例如我上面提供的 ffmpeg 轉(zhuǎn)碼后的 WAV 文件,其文件頭的長度就是 78 個(gè)字節(jié)。對于文件頭長度不一致的問題,我的解決方法是從 37 個(gè)字節(jié)開始,2 個(gè) 2 個(gè)字節(jié)地讀取,直到讀取到“da”和“ta”,之后再往后讀取 4 個(gè)字節(jié)的 int 值作為 data 數(shù)據(jù)長度。讀取到 header 后,后面播放的就不用說了,復(fù)用上面播放 PCM 的代碼即可。
需要說明的是我只是從網(wǎng)上隨機(jī)下載了幾個(gè) wav 格式音頻測試了下是可以正常播放的,并沒有經(jīng)過廣泛驗(yàn)證和對常見的 WAV 文件頭格式的考證,所以可能還存在兼容問題。
經(jīng)過這些以上的學(xué)習(xí)以及眾多資料的查閱,對 Android 端音頻開發(fā)有了一些小小的認(rèn)識。后面還會學(xué)習(xí)一下使用 LAME 將 PCM 轉(zhuǎn)碼為 MP3,并實(shí)現(xiàn)一些真正意義上的音頻播放器的基礎(chǔ)功能等。再后面會學(xué)習(xí)一些視頻方面的知識,包括 MediaExtractor、MediaMuxer 解析、封裝 MP4 文件、OpenGL ES 渲染圖像、MediaCodec 對音視頻的硬編、硬解等,并使用一些流行的開源項(xiàng)目例如 ffmpeg 實(shí)現(xiàn)一些炫酷的視頻處理功能,希望可以在 Android 音視頻開發(fā)這一塊能有所深入,學(xué)習(xí)過程中的一些收獲和困惑也會堅(jiān)持記錄下來。
此路迢迢,與君共勉。
以上源碼見 Github