H5音頻處理——踩坑之旅

隨着公司產品的業務擴展,今年算是和瀏覽器的錄音功能硬磕上了。遇到了很多奇葩的問題以及一些更多的擴展吧~這裏記錄一下分享給一樣遇到問題後腦袋疼的各位。php

解析base64的pcm數據進行播放

這個場景仍是存在的。在websocket和server的交互上可能不存在問題。可是若是是原生應用間的交互,爲了保證數據的一致性,只傳string的狀況下就須要用到了。前端

  1. 解析base64變爲arrayBuffer.vue

    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個字節)ios

    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進行一次拼裝git

    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數據流還能夠進行組裝拼接github

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

    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流經過方法回調提供給到網頁中進行後續的處理)。這算是我第一次對接原生應用,再加上我司目前還不須要這方面開發人員,因此可能踩了一些在你們看來常識性的問題。也稍做整理:chrome

原生應用和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這些,可是一直沒時間整理,若是你們有興趣的話~後面能夠整理一下

相關文章
相關標籤/搜索