# Java 音訊處理 ## I. 背景知識說明 ### MP3 檔案的介紹 MP3 是一種壓縮音訊的格式,它利用人耳的聽覺特性來減少音訊檔案的大小。 每個 MP3 文件由多個小的塊所組成,這些小塊稱為幀(frame);而每一幀都開始於一個幀頭,包含如以下相關重要訊息: * 位率(bitrate):表示壓縮音檔被壓縮到什麼程度,單位是 bps(bits per seconds,一秒的音訊需要多少 bit)。 * 採樣率(sampling rate):每秒鐘聲音取樣的次數,單位為 Hz * 頻道模式(channel):該值音訊聲道數量,1 代表單聲道,2 代表雙聲道(通常是立體聲)。 * 版本(version):指示 MP3 的版本。 以上所列出的是多數 frame header 存在的一些資訊,其餘資訊會依不同音訊檔案而有所不同。 而在幀頭之後的則是幀資料,其長度取決於位率與採樣率,這部分包含實際的音訊資料。也就是說,**每個 frame 的長度可能不固定,也可能固定,這取決於 bitrate 來決定**。 ### MP3 檔案的切割 一般使用音訊剪輯軟體在切割音訊檔案的方式必須考量到如何處理 MP3 文件的 frame 結構,因為直接在檔案中任意切分可能會導致播放錯誤。這是因為 MP3 文件由許多幀組成,每幀包括幀頭和隨後的幀數據,如果在非 frame 邊界上切分,就會破壞音訊數據的完整性。 **一般標準音訊軟體在正確處理 MP3 文件的切割步驟** 1. 保留 frame header 在分割 MP3 檔案時,必須確保每段新的 MP3 檔案從完整的 frame 開始,這代表每個新的音訊文件都應該要從一個 frame header 開始,並包含一連串完整的 frame(s)。 2. 遵循 frame 的邊界 切割音訊檔時,應該要找出每個 frame 的邊界並根據這些邊界來切割原始檔案,每個 MP3 frame 都有可能有不同的大小,所以我們在切割時需要從檔案開頭開始一個個解析每個 frame header,來確定每個 frame 的起始位置和長度。 3. 計算分割點: 當我們分割檔案時,應該要基於總幀數來計算而不僅僅是檔案大小,也就是說,最正確的計算方式應先遍歷整個檔案,計算總幀數,然後再根據這個數值決定如何分割檔案。 :::danger **關於 Assignment11**: 在本次作業(assignment11)中,由於每個 frame 的計算涉及該 MP3 檔案的 bitrate、sampling rate 等值,而這些資訊並未於題目中提供,因此本次作業請直接以「檔案大小」而非「幀個數」來切割音訊檔。 (這種做法雖然無法確保每個音訊檔都可以在任何播放器中正常播放,但以多數 MP3 與本次作業所使用的範例測資而言,以 file size 進行切割的方式均可正常播放。) ::: 如果是在非幀邊界上切割 MP3 檔案的話通常會導致播放問題,如聲音的跳躍或是各種噪音,然而,直接以音檔大小來切割檔案的方式之所以在多數撥放器均可正常播放的原因通常如下: * **幸運的幀邊界對齊**:我們利用音檔大小進行切割時計算出的開始位置可能剛好位於幀的邊界上,而這種情況下即使是直接切割也不會破壞音訊數據的完整性。然而,這種情況比較偶然,不能保證每次都正確。 * **撥放器的容錯能力**:現代多數的音樂播放器都具有一定的容錯能力,能夠跳過損壞的幀或是使用錯誤修正演算法來處理部完整的數據,這可能使得即使音檔在非理想的位置上被切割,但我們播放出來時仍「看似正常」。 * **切割位置與實際數據**:通常導致播放問題的數據為代表該音檔相關資訊(即 MP3 檔的標籤:如 ID3 標籤),這個資訊可能位於文件的開頭或是結尾,如果在音訊切割上沒有觸及這些關鍵的音訊編碼數據的話,可能會意外地避開導致播放問題的部分。 ## II. Byte 讀寫檔 針對 Byte 型別的讀寫檔案,在 Java 中,以下列兩種讀寫方式最為常見: * 使用 `FileInputStream`/` FileOutputStream` 類別,這兩個類別主要特殊之處在於只能以 Byte 類型來讀寫檔案 ```java public class test { public static void main(String[] args) throws IOException { File inputFile = new File("./sample.in"); FileInputStream readFile = new FileInputStream(inputFile); int character; while((character = readFile.read()) != -1) { System.out.print(Integer.toString(character) + " "); } readFile.close(); } } ``` 在這個例子中,我們所讀取的`sample.in`檔案內容如下: ``` This is a sample file. This is a sample file. ``` `FileInputStream`中`read()`是用來讀取並返回一個 Byte 的數據,以整數的形式回傳,這個整數會介於 0 ~ 255 之間,而如果達到文件末端的時候,則會回傳 `-1` 表示沒有更多的數據可以讀取。 當我們針對 Byte 檔案要寫檔時,則可以用 `FileOutputStream` 類別來實現。 與 `FileInputStream` 類似,`FileOutputStream` 也有一個 `write(byte[] array)` 用以寫入 Byte 型別的檔案內容。 * 使用 `RandomAccessFile` 類別,這個類別的主要特點在於存取檔案的位置可以在檔案中隨意移動,也就是我們可以任意指定檔案讀寫的位置 ```java public class test { public static void main(String[] args) throws IOException { RandomAccessFile input = new RandomAccessFile("./sample.in", "r"); int position = 10; input.seek(position); int character; while((character = input.read()) != -1) { // 讀取到文件結尾 System.out.print(Integer.toString(character) + " "); } input.close(); } } ``` 在這個例子中,我們所讀取的`sample.in`檔案內容如下: ``` This is a sample file. This is a sample file. ``` 與剛剛 `FileInputStream` 不同的地方在於,我們可以指定從哪個 Byte 的位置開始讀檔。 與上方說明類似,如果要以 `RandomAccessFile` 來同時讀寫檔的話,只要設定 `RandomAccessFile raf = new RandomAccessFile(filePath, "rw");` 即可。 ## III. MP3 檔案於 Byte 型別的顯示意義 當我們讀取 MP3 檔案中的每個 Byte 時,我們會發現檔案印出的內容會是一個類似下方的內容: ```java public class test { public static void main(String[] args) throws IOException { RandomAccessFile inputFile = new RandomAccessFile("./audio.mp3", "r"); byte[] inputBuffer = new byte[1024]; inputFile.seek(10); inputFile.read(inputBuffer); System.out.println("inputFile: "); for(int i = 0; i < inputBuffer.length; i++) { System.out.print(inputBuffer[i] + " "); } } } ``` ![image](https://hackmd.io/_uploads/HyZBsommA.png) 針對 MP3 檔案而言,這些每一個的 Byte 數值不像文本文件那樣可以直接以 ASCII 編碼的方式對應到可讀的字元,它們是經過壓縮後的二進位音訊數據,如果要解讀的話必須詳細針對 MP3 編碼進行理解。 通常針對這些數據的解讀方式,最簡單的方式是直接將數據寫入 MP3 文件中,然後以標準的媒體播放器去播放它,這樣聽到的實際音訊內容才會有意義。