Android 音視頻開發(fā)(一):PCM 格式音頻的播放與采集

什么是 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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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