1. 程式人生 > 實用技巧 >Android BUG記錄---音訊資料丟失

Android BUG記錄---音訊資料丟失

最近在做專案,有一個功能是可以傳送語音訊息(手指按下錄音,鬆開傳送)。功能實現是用 AudioRecord 錄音獲取 PCM 資料,然後手動編碼儲存。但是測試反饋說錄製出來的語音,前幾百毫秒丟失了,問我怎麼回事。程式碼是從成熟的專案移植過來的,沒有過改動,移植前也沒有丟失的情況,所以我懷疑是系統的問題。我試了下系統的錄音軟體,發現存在同樣的問題。於是我問了下系統的開發工程師,他們給的解釋是系統底層的啟動也需要時間,所以有延時。

下面我放上理想、實際和修復方案流程圖,方便理解。

從理想流程中,我們知道了幾個關鍵的時間點:

  1. 我們呼叫介面,開始錄音的時間點
  2. 使用者看到提示,開始說話的時間點(顯示彈框)
  3. 音訊開始採集資料的時間點

在理想流程中,2/3 應該是同時發生的。這樣音訊就沒有丟失。但是實際情況是,2 早於 3 發生,導致使用者說的部分語音丟失了。至於出現這種情況的原因嘛,自然就是硬體延時咯。因為雖然音訊服務是系統的常駐服務,不需要手動開啟和關閉,但是系統底層的硬體,也和 APP 的相關操作一樣,用時才開啟,不用就關閉。關了再啟動,自然也就有啟動耗時(此處表現就是音訊採集延時/音訊丟失)。那怎麼解決這個問題呢?這裡有兩種思路:

  1. 提前初始化硬體
  2. 延後使用者說話的時機

先說第一種思路,硬體的初始化是在AudioRecord.startRecording()呼叫後才開始初始化的。但是我們並不清楚使用者什麼時候按下手指(即使用者何時觸發錄音),自然也就無法判斷提前初始化的時機。

而第二種思路呢,從理論上和實際上看,都有可行性。因為使用者按下手指後,一般不會立刻開始說話,而是會等到錄音彈框出現後再說(錄音彈框的作用就是提示使用者可以說話了)。那我們自然可以延後使用者說話的時機。

從流程圖中,我們可以看出,音訊的採集行為是發生在錄音過程中的。,我們呼叫AudioRecord.startRecording()開始錄音,此時系統就判定我們開始錄音了,方法AudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING會返回 true,但此時硬體可能還在初始化,無法錄音。這就說明了一件事:硬體的啟動延時,導致了系統的錄音判斷不準確。需要自己維護錄音中的狀態判斷。

順著第二種思路研究,會發現我們需要解決的最關鍵的問題是:如何判斷硬體初始化完成?

我們先做一個假設:如果硬體還在初始化,不能採集音訊,而我們說話了,那麼音訊自然不會被採集上。此時我們去讀聲音資料,自然是讀不到聲音資料的,可表現為音量為 0。但是如果音訊硬體可用,採集到的音訊音量也就不會為 0。注意,此處的音量,不是系統設定中的音量,而是 PCM 音訊資料流的音量。基於這個判斷,我們繼續研究,問題則又轉換成了:如何從採集的 PCM 音訊流得到音訊音量,以及如何判斷音訊是否有效。

音量的計算

在日常生活中,我們通常用分貝來形容聲音的大小。那麼此處,我們也採用分貝來判斷音訊是否可用。分貝的定義可以百度,分貝是個相對值,其中有兩個關鍵的公式:

最關鍵的就是第二個了。通過 PCM 資料,我們可以很簡單的得到振幅,也就可以很簡單的得到分貝的大小(PCM 介紹見這篇文章)。此處引用另一篇文章的一段話,來說明如何獲取分貝。

在程式設計中,我們可以用以下公式計算兩個聲音之間的分貝動態範圍,單位為分貝:dB = 20 * log(A1 / A2)。
其中 A1 和 A2 是兩個聲音的振幅,在程式中表示每個聲音樣本的大小。聲音取樣大小(也就是量化深度)
為 1bit 時,動態範圍為 0,因為只可能有一個振幅。取樣大小為 8bit 也就是 1 個位元組時,最大振幅是最
小振幅的 256 倍。因此,動態範圍是 48 分貝,計算公式:dB = 20 * log(256)。48 分貝的動態範圍大約
是一個安靜房間和一臺執行著電動割草機之間的區別。如果將聲音取樣大小增加一倍到 16bit,產生的動態範
圍則為 96 分貝,計算公式:dB = 20 * log(65536)。這非常接近聽力最低閾值和產生痛感之間的區別,這
個範圍被認為非常適合還原音樂。

現在我們知道了原理,那麼再回過頭來介紹下我們現在的程式碼,下面是我們現在的核心程式碼。

/**
 * 判定是否正在錄音
 *
 * @return true:正在錄音
 */
public boolean isRecording() {
    // 系統的介面
    return mAudioRecord != null && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING;
}
/**
 * 開始錄音
 */
public void startRecord() {
    try {
        mAudioRecord.startRecording();
        // mRecordCallback 是應用自定義的回撥,回撥後即顯示錄音中的彈框,提示使用者說話
        mRecordCallback.onStartRecord();
        if (isRecording()) {
            // 該執行緒不停的從 AudioRecord 讀取資料,並回調給應用
            new RecordDataThread().start();
        } else {
            // 發生錯誤
            mRecordCallback.onStartRecordError(new Exception("未在錄音"));
        }
    } catch (Exception e) {
        mRecordCallback.onStartRecordError(ERROR_START_DEVICE);
    }
}

從上面的程式碼中,可以看出,我們開始錄音,和錄音中的判斷,都是呼叫的系統介面。要修復音訊丟失的問題,上面的程式碼就不可信了,需要改造。

首先判斷我們上面的假設是否正確。假設現在有一段 16 位深的 PCM 音訊資料 data,現在我們可以通過下面的方法,拿到分貝。

for (int i = 0; i < pcmAry.length; i += 2) {
    // 帶入振幅,根據公式算出分貝
    Log.d(TAG, "當前音量大小:" + 20 * Math.log10(getShort(pcmAry, i)));
}

/**
 * byte 流小端排序,第 1 個位元組作為 16 位資料的低 8 位,第 2 個位元組作為 16 位資料的高 8 位,就得到了一個 16 位深音訊的取樣振幅
 */
private short getShort(byte[] data, int start) {
    return (short) ((data[start] & 0xFF) | (data[start + 1] << 8));
}

採集一段音訊,看看實際情況。

上面的截圖驗證了我們的猜想,當錄音硬體未啟動成功時,採集不到音訊,而我們根據拿到的無效音訊計算得到的分貝,都是無效的數值。但是當音訊資料可用時,得到的分貝就是正常的數值了。

根據驗證得到的結論,我們下面就可以判斷音訊是否可用了。

// 當前是否正在錄音的標誌
private boolean isRecording = false;
/**
 * 判斷音訊是否可用
 */
private boolean isRecording(Double voiceDB) {
    return voiceDB != null && !voiceDB.isNaN() && !voiceDB.isInfinite();
}

根據思路,我們需要在迴圈讀取 PCM 資料時(迴圈取決於系統提供的錄音中的介面判斷),判斷音訊是否可用,可用時,回撥開始錄音,告訴使用者可以開始說話了,並記錄下錄音中的狀態。就像上面的流程圖那樣。

注意:此判斷方法僅可在開始錄音(AudioRecord.startRecording())後,音訊硬體還未啟動完成時呼叫,用於判斷何時可以錄音。即判斷 無 ---> 有,而不是判斷 有 ---> ?。另外,雖然實際開始錄音的時間點和系統判斷的時間點不一致,但是結束錄音的時間點應該保持一致,否則就會將問題複雜化。當然,結束錄音的時間點保持一致是最正確的選擇,因為使用者要結束錄音,那就可以停止採集音訊了。

上面的 BUG 及 BUG 修改思路就說明完了。這個 BUG 我暫未修改,因為時間有限,且影響面比較廣。跟產品等溝通後,決定暫不處理。如果要修復,就放到後面的迭代中。到時候有時間,且能充分測試。所以這個修改思路,我實際上只做了部分而非完全驗證。如果思路有誤或者思路根本不可用,請指出。