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