H5音訊處理——踩坑之旅

嘻嘻啊☺️發表於2019-03-14

隨著公司產品的業務擴充套件,今年算是和瀏覽器的錄音功能硬磕上了。遇到了不少奇葩的問題以及一些更多的擴充套件吧~這裡記錄一下分享給同樣遇到問題後腦殼疼的各位。

解析base64的pcm資料進行播放

這個場景還是存在的。在websocket和server的互動上可能不存在問題。但是如果是原生應用間的互動,為了保證資料的一致性,只傳string的情況下就需要用到了。

  1. 解析base64變為arrayBuffer.

    function base642ArrayBuffer() {
    			const binary_string = window.atob(base64); // 解析base64
          const len = binary_string.length;
          const bytes = new Uint8Array(len);
          for (let i = 0; i < len; i++) {
            bytes[i] = binary_string.charCodeAt(i);
          }
      		// 如果不`.buffer`則返回的是Unit8Array、各有各的用處吧
      		// Unit8Array可以用來做fill(0)靜音操作,而buffer不行
          return bytes.buffer;
    }
    複製程式碼
  2. 由於瀏覽器不能支援播放pcm資料,所以如果後端server”不方便“給你加上wav請求頭.那我們需要自己造一個wav的頭(也就是那44個位元組)

      function buildWaveHeader(opts) {
        const numFrames = opts.numFrames;
        const numChannels = opts.numChannels || 1;
        const sampleRate = opts.sampleRate || 16000; // 取樣率16000
        const bytesPerSample = opts.bytesPerSample || 2; // 位深2個位元組
        const blockAlign = numChannels * bytesPerSample;
        const byteRate = sampleRate * blockAlign;
        const dataSize = numFrames * blockAlign;
    
        const buffer = new ArrayBuffer(44);
        const dv = new DataView(buffer);
    
        let p = 0;
    
        p = this.writeString('RIFF', dv, p); // ChunkID
        p = this.writeUint32(dataSize + 36, dv, p); // ChunkSize
        p = this.writeString('WAVE', dv, p); // Format
        p = this.writeString('fmt ', dv, p); // Subchunk1ID
        p = this.writeUint32(16, dv, p); // Subchunk1Size
        p = this.writeUint16(1, dv, p); // AudioFormat
        p = this.writeUint16(numChannels, dv, p); // NumChannels
        p = this.writeUint32(sampleRate, dv, p); // SampleRate
        p = this.writeUint32(byteRate, dv, p); // ByteRate
        p = this.writeUint16(blockAlign, dv, p); // BlockAlign
        p = this.writeUint16(bytesPerSample * 8, dv, p); // BitsPerSample
        p = this.writeString('data', dv, p); // Subchunk2ID
        p = this.writeUint32(dataSize, dv, p); // Subchunk2Size
    
        return buffer;
      }
      function writeString(s, dv, p) {
        for (let i = 0; i < s.length; i++) {
          dv.setUint8(p + i, s.charCodeAt(i));
        }
        p += s.length;
        return p;
      }
      function writeUint32(d, dv, p) {
        dv.setUint32(p, d, true);
        p += 4;
        return p;
      }
      function writeUint16(d, dv, p) {
        dv.setUint16(p, d, true);
        p += 2;
        return p;
      }
    複製程式碼
  3. 把頭和pcm進行一次拼裝

    concatenate(header, pcmTTS);
    function concatenate(buffer1, buffer2) {
        const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
        tmp.set(new Uint8Array(buffer1), 0);
        tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
        return tmp.buffer;
      }
    複製程式碼
  4. 轉成可播放buffer流,可以用來獲取時間,如果是多段pcm資料流還可以進行組裝拼接

    audioCtx.decodeAudioData(TTS, (buffer) => { 儲存起來準備播放 });
    // buffer.duration 可以用來判斷播放時長
    // buffer
    複製程式碼
  5. 播放

    const source = audioCtx.createBufferSource();
    const gainNode = audioCtx.createGain();
    source.buffer = buffer;
    gainNode.gain.setTargetAtTime(0.1, audioCtx.currentTime + 2, 5);
    source.connect(gainNode);
    gainNode.connect(context.destination);
    source.start('需要播音的時長 簡單的可以用buffer.duration或者自己計算拼接後的長度邏輯' + this.context.currentTime);// 這裡必須要加上currentTime
    複製程式碼

錄音後發現手機端公放情況下噪音、回聲嚴重

在業務需求中,有一個比較坑的需求。我們產品的場景是模擬一個機器人和使用者的通訊對話過程。中間涉及機器人音訊播放和使用者的說話錄音(因為功能上的需求,要求機器人播音時仍然錄音來保證搶話邏輯的存在)。本來這個方案在佩戴耳機的場景下仍然能夠做到表現還不錯,但是在一次定製化需求下要求實現以上功能的情況下手機音訊公放,不許佩戴耳機。然後我們就崩潰了,花了很多時間去調研實現(其實這塊沒前端啥事,但是可以整理一下我的認知)。

  1. **VAD做降噪邏輯。**vad是聲音活動檢測,檢測有沒有聲音。和降噪其實是兩回事。但是通過加入Vad的演算法模組可以起到一定的降噪作用,其做法是粗暴地預設人聲比環境聲音要大,去除聲音小的音源。但是並不算是標準的降噪處理。
  2. 降噪一般來怎麼實現。因為噪音在聲學層面和人聲沒有顯著差別,純軟體演算法實現降噪是很難的。所以一般都是硬體過濾一次然後再到演算法層面.噪音採集進來之後很難過濾。綜上所述降噪主要還是靠硬體裝置(麥克風陣列),但是經過檢驗不同手機公放下硬體裝置都不一致,而且公放的邏輯底下,華為mate20pro/iphoneX等其實都還是會有明顯的回聲,於是後面我們為了體驗的問題被迫砍了需求(只在輪到使用者說法的時候才錄音)——後續可能還會繼續調研吧,聯絡到的第三方方案暫時沒能成功引入驗證,所以也不能肯定這個方向完全不可行。
  3. 有沒有專門的降噪演算法。專門的降噪演算法肯定是有的,如果可以場景是安卓和IOS原生裝置下,可以通過呼叫底層的API在本地直接實現降噪甚至是回聲的消除,相當於把演算法模組以sdk的形式直接安裝到應用上。但是如果是在web端那就沒辦法必須傳到雲上進行演算法降噪。
  4. 最好的做法。在演算法能力侷限的情況下還是得去引導客戶去佩戴麥克風。因為就場景而言,這種場景下要實現比較好的對話效果提取資訊對ASR的質量是有非常高要求的。通過物理裝置降噪,能夠很大程度地減輕演算法端的壓力。畢竟物理單元還可以實現主動降噪(市面上的那些主動降噪耳機)。

使用webview

因為各種原因吧,我們開始除錯怎麼原生和網頁互動(原生應用負責採集音訊,音訊的pcm流通過方法回撥提供給到網頁中進行後續的處理)。這算是我第一次對接原生應用,再加上我司目前還不需要這方面開發人員,所以可能踩了一些在大家看來常識性的問題。也稍作整理:

原生應用和webview的互動

以前一直以為互動形式是帶有回撥函式等花裡胡哨的操作的。對接上後才知道,兩端的呼叫都只能用簡單的方法呼叫傳參。這就導致一個問題,我們和原生應用的互動需要把方法繫結在window下,而繫結在window下的方法沒有擁有vue的this上下文,所以為了打通原生應用的pcm資料流能正常下發到vue例項中進行邏輯處理,我寫了一個簡單的事件訂閱者模式,通過訂閱、通知的形式來實現了。

如何看控制檯的日誌

在嵌入webview之後最大的問題大概就是我們要怎麼看chrome的日誌了。可能現在採用的方式還是一個比較蛋疼的實現方式,我分別下載了IOS的開發工具和安卓的開發工具,然後讓他們幫忙把環境給我搭起來,之後調整就是我自己的事情了。這樣的方法有個好處,就是假如我遇到一些小問題(涉及原生的改動),我可以直接自己查一下上手改一些小邏輯,不需要依賴別人,提高一定的效率。

這裡還有個坑,但是就是安卓開啟了chrome的除錯模式後,開啟控制檯會出現404報錯。其實這需要你用魔法上網之後才能正常訪問。不然不管你咋搗鼓都不會成功滴。

許可權相關的注意點

webview嵌入原生應用後有很多許可權上的問題,例如是否允許localstorage、是否允許非法的安全證照(本地開發會偽造證照來模擬https)、是否允許開啟錄音許可權、https是否允許載入http資源、甚至細緻到播音等等。遇到這個問題我的辦法是,儘可能和搭檔描述清楚我的頁面會做什麼操作,然後由他們去判斷給你開什麼許可權

IOS的愛恨糾纏

IOS的坑實在太多了,希望能給大家踩完這些坑。

wkbview下無法支援web端錄音

這個其實是個比較蛋疼的點。一開始我發了一段用來檢驗瀏覽器相容性的程式碼,讓合作伙伴(他們負責寫原生app嵌入我們的webview)幫忙先簡單地試一下相容性是否有問題以敲定我們方案。結果估計是沒溝通好,在臨近專案上線前,嘗試把我們的頁面嵌入時才發現原來丫的不支援這個功能。這算是狠狠踩了坑,後面沒辦法只能選擇臨時更換方案,在嵌入IOS的webview使用原生的錄音,其他環境邏輯繼續走網頁錄音。

**總結一下,IOS12版本(現階段最新版本)safari能夠支援網頁端錄音,但是使用wkwebview(原生app嵌入webview)的場景下不支援這個功能。**有看到在github上有人在IOS11時說預估IOS12會支援這個功能。對於我們而言,這樣相容性比較差的方案肯定是毫不留情給它廢棄掉。

safari下多次呼叫audioCtx.xxx後報錯null is not an object

在safari下我們針對每一次錄音和播放機器人聲音的操作都會生成一個audioContext的例項,在chrome下不管進行多少次操作都沒有問題。但是切換到safari後,發現頁面最多不能操作5次,只要操作第5次就必然報錯。按理說每次的關係應該都是獨立的,在確保現象後,找到這篇文章audiocontext Samplerate returning null after being read 8 times。大概意思是,呼叫失敗的原因是因為audioCtx不能被建立超過6個,否則則會返回null。結合我們的5次(這個數值可能有一定偏差),可以很直觀地判斷到問題應該就出在這裡——我們的audio示例並沒有被正常銷燬。也就是程式碼中的audioCtx = null;並沒有進入到垃圾回收。同樣藉助MDN文件,發現這個方法.

AudioContext.close();

關閉一個音訊環境, 釋放任何正在使用系統資源的音訊.

於是過斷把audioContext = null修改成audioContext.close()完美解決。

safari下audio標籤無法獲取duration,顯示為Infinity

在safari下,從遠端拉回的音訊檔案放到audio標籤後,獲取總時長顯示為Infinity.但是在chrome下沒有這個問題,於是開始定位問題。首先,看這篇文章audio.duration returns Infinity on Safari when mp3 is served from PHP,從文章中的關鍵資訊中提取得到這個問題很大概率是由於請求頭設定的問題導致的。所以我嘗試把遠端的錄音檔案拉過來放到了egg提供的靜態檔案目錄,通過靜態檔案的形式進行訪問(打算看看請求頭應該怎麼修改),結果驚喜的發現egg提供的處理靜態檔案的中介軟體在safari下能完美執行。這基本就能確定鍋是遠端服務沒有處理好請求頭了。同時看到MDN的文件介紹對dutaion的介紹.於是能判斷到,在chrome下瀏覽器幫你做了處理(獲取到了預設的長度),而safari下需要你自己操作。

A double. If the media data is available but the length is unknown, this value is NaN. If the media is streamed and has no predefined length, the value is Inf.

當然看到length的時候我一度以為是contentLength,結果發現最下面的答案中還有一句:

The reason behind why safari returns duration as infinity is quite interesting: It appears that Safari requests the server twice for playing files. First it sends a range request to the server with a range header like this:(bytes:0-1).If the server doesnt’ return the response as a partial content and if it returns the entire stream then the safari browser will not set audio.duration tag and which result in playing the file only once and it can’t be played again.

大概的意思就是在safari下獲取音訊資源會傳送至少兩次的請求,第一次請求會形如(bytes: 0-1),如果服務端沒有根據這個請求返回相應的位元組內容,那麼safari就不會幫你解析下一個請求拿回來的全量音訊資料,失去一系列audio標籤的功能特性。於是對於請求,我們可以這麼粗糙的解決:

    const { ctx } = this;
    const file = fs.readFileSync('./record.mp3');
    ctx.set('Content-Type', 'audio/mpeg');

    if (ctx.headers.range === 'bytes=0-1') {
      ctx.set('Content-Range', `bytes 0-1/${file.length}`);
      ctx.body = file.slice(0, 1);
    } else {
      ctx.body = file;
    }
複製程式碼

當然這個處理是很粗糙的處理方式,我反觀看了一下koa中介軟體實現的static-cache它能在safari下正常執行,但是卻沒有上面的程式碼。所以我覺得,這上面的程式碼則是一段偏hack形式的實現。當然現在還沒有找到正確的解題思路。

不支援/deep/選擇器

這個問題暫時沒有響應的解決方案。只能是把需要修改到子元件的樣式提取到不帶scope的style標籤上來做到。暫時沒有找到比較平滑的相容方式。

ios呼叫停止原生錄音,導致wkwebview進入假死狀態(無法使用路由跳轉及傳送請求等)

這個其實是屬於原生錄音的問題,但是因為一開始以為是前端的問題所以花了很多時間才把問題定位了出來。記錄在這裡以防別的小夥伴也踩坑。

在專案中的程式碼,結束一次的會話會進行各種儲存操作和路由跳轉操作。但是在接入ios的錄音功能後就發現頁面的請求雖然是顯示已發出,但是後臺卻遲遲沒有收到。——終終於定位到是由於呼叫了ios的錄音停止而導致的這個問題,大概是頁面進行一些任務佇列相關的操作時就會卡死(如果只是console.log並不會)

這裡也稍微貼一下ios的解決方法

// 停止錄音佇列和移除緩衝區,以及關閉session,這裡無需考慮成功與否
AudioQueueStop(_audioQueue, false);
// 移除緩衝區,true代表立即結束錄製,false代表將緩衝區處理完再結束
AudioQueueDispose(_audioQueue, false);
複製程式碼

呼叫context.createBufferSouce.stop()報錯

在嵌入webview後,頁面中斷的時機,需要將當前正在播放的音訊都中斷掉。而在ios下執行這個方法會報錯(有一些原因導致需要重複執行)。對於這種報錯,選擇採用了最簡單的try {} catch{}住,因為在其他情況下都沒有,測試了好幾種情況應該都沒出其他問題


後記,其實吧這段時間還做了很多事情。像什麼web-rtc這些,但是一直沒時間整理,如果大家有興趣的話~後面可以整理一下

相關文章