執行緒安全引起的錄音雜音電流音問題

劉俊發表於2018-03-21

前段時間寫了一個錄音模組,需求是:『錄音的時候實時語音轉文字,實時計算音量大小,實時進行 MP3 轉碼儲存為檔案』

首先進行需求分析,確定技術方案:

  1. 使用 AudioRecord 進行錄音,實時獲取原始音訊資料
  2. 將音訊資料傳遞給第三方語音轉文字 SDK 進行處理
  3. 對音訊資料進行處理,計算出音量大小
  4. 對音訊資料進行 MP3 編碼
  5. 將編碼後的資料寫入 MP3 檔案

整個業務流程如上,只不過我們為了效率和解耦,將每個處理邏輯獨立開來使用多執行緒進行併發處理。

具體流程見下圖:

錄音流程圖

程式碼擼起來

下面的虛擬碼全部使用 kotlin 展示,不熟悉 kotlin 沒關係,只需要關注具體的業務邏輯。

音訊資料採集

首先建立一個執行緒給 AudioRecord 進行錄音採集:

  val emitter: FlowableProcessor<ShortArray>
  val isRecording = AtomicBoolean()

  override fun run() {
    var buffer = ShortArray(bufferSize)
    while (isRecording.get()) {
      val readSize = audioRecord.read(buffer, 0, bufferSize)
      if (readSize > 0) {
        //將資料使用 RxJava 傳送出去
        emitter.onNext(buffer)
      }
    }
  }
複製程式碼

我們在子執行緒中讀取到音訊資料,並且通過 RxJava 將資料向下傳遞。(用什麼傳遞不重要,重要的是將資料傳遞給下一層去進行處理)

對資料進行處理

外部接收 RxJava 的事件,對音訊資料進行處理 (再次提醒,不需要在意細節,主要關注業務流程)

  // 使用 observeOnIo() 操作將執行緒切換到 IO 執行緒
  recorder?.start()?.observeOnIo()?.subscribe{ it:ShortArray ->
    //此時的程式碼和錄音採集的程式碼分別執行在不同的執行緒上了

    //計算音量大小
    val volume = calVolume(it)

  }


  recorder?.start()?.observeOnIo()?.subscribe{ it:ShortArray ->
    //此時的程式碼和錄音採集的程式碼分別執行在不同的執行緒上了

    //將 ShortArray 轉換為 ByteArray
    var pcmBuffer: ByteArray = ...
    it.toByteArray(pcmBuffer)

    //語音轉文字
    ...
  }

  recorder?.start()?.observeOnIo()?.subscribe{ it:ShortArray ->
    //此時的程式碼和錄音採集的程式碼分別執行在不同的執行緒上了

    //進行 MP3 編碼
    val encode = mp3Encode(it)
    if (encode != null && encode > 0) {
      // 將編碼後的資料寫入檔案
      mp3Stream.write(mp3Buffer, 0, encode)
    }

  }

複製程式碼

整個業務流程就是這樣,我自己使用的手機和公司所有的測試機,試聽錄製出來的 MP3 檔案都沒有問題。

開開心心的打包,測試,上線。

然後你懂的,有些使用者錄製出現雜音、電流音、聲音斷斷續續。??

機智的同學可能通過標題已經猜到了問題的原因,但我當時沒有手機進行問題復現,為了解決這個問題可是花了很大的功夫才定位到問題所在。

解決雜音問題

因為我們在錄音採集時將資料讀取到 buffer 物件中,然後將 buffer 物件通過 RxJava 向下傳遞,因為 RxJava 的下游都開啟了非同步執行緒去處理事件,那麼在錄音採集的死迴圈中不等當前的資料進行 MP3 編碼完畢就對 buffer 物件寫入新採集到的音訊資料,這個時候 MP3 編碼出來的音訊資料就被汙染了。

  val emitter: FlowableProcessor<ShortArray>
  val isRecording = AtomicBoolean()

  override fun run() {
    var buffer = ShortArray(bufferSize)
    while (isRecording.get()) {
      // 讀取音訊資料到 buffer 中
      val readSize = audioRecord.read(buffer, 0, bufferSize)
      if (readSize > 0) {
        //將 buffer 傳送出去,因為下游是非同步處理,所以執行完畢直接開始下次迴圈
        emitter.onNext(buffer)
      }
    }
  }
複製程式碼

要解決這個問題很簡單:

  // 將 buffer 資料 copy 一份進行傳遞,這樣就不會修改下游的資料了
  emitter.onNext(buffer.copyOf())
複製程式碼

但是使用 copy 的方式會頻繁的建立、銷燬 ShortArray 物件,能不能優化一下呢?

我們可以使用物件池來管理 ShortArray,這樣就不會頻繁的進行建立、銷燬操作。在 Android 的 support.v4 包中有一個 Pools 類實現了簡單的物件池功能:

  val bufferPool = Pools.SynchronizedPool<ShortArray>(10)

  fun acquireBuffer(bufferSize: Int): ShortArray {
    var buffer = bufferPool.acquire()
    if (buffer == null || buffer.size != bufferSize) {
      buffer = ShortArray(bufferSize)
    }
    return buffer
  }

  fun releaseBuffer(shortArray: ShortArray) {
    try {
      bufferPool.release(shortArray)
    } catch (e: Exception) {
      Timber.e(e)
    }
  }

  override fun run() {
    while (isRecording.get()) {
      //通過物件池獲取 buffer 物件
      val buffer = acquireBuffer(bufferSize)
      // 讀取音訊資料到 buffer 中
      val readSize = audioRecord.read(buffer, 0, bufferSize)
      if (readSize > 0) {
        //將 buffer 傳送出去,下游處理完畢後呼叫 releaseBuffer 對 buffer 物件進行釋放
        emitter.onNext(buffer)
      }
    }
  }

複製程式碼

總結

很簡單的一個多執行緒併發問題,但是當我們自己不能復現的時候,還是帶來了很大的麻煩。 這種問題在編寫 emitter.onNext(buffer) 這行程式碼的時候就應該要考慮到執行緒安全問題,並且我之前做直播截圖的時候也遇到過類似的問題,擷取直播流的畫面幀儲存為圖片,因為截圖的操作不會很頻繁,當時是直接 copy 一份畫面幀的資料儲存為圖片。

可是以前沒有寫部落格記錄這種小問題,導致遇到類似的問題儘量不記得了。所以這次記錄下來??。

歡迎關注微信公眾號:**大腦好餓**,更多幹貨等你來嘗

微信公眾號

相關文章