Vue +WebSocket + WaveSurferJS 實現H5聊天對話交互


引言

在與實現了語音合成、語義分析、機器翻譯等算法的后端交互時,頁面可以設計成更為人性化、親切的方式。我們采用類似於聊天對話的實現,效果如下:

  • 智能客服(輸入文本,返回引擎處理后的文本結果)

與智能客服對話

  • 語音合成(輸入文本,返回文本以及合成的音頻)
    語音合成
    如上圖所示,返回文本后,再返回合成出的音頻。
    音頻按鈕嵌在對話氣泡中,可以點擊播放。

  • 語音識別(在頁面錄制語音發送,頁面實時展示識別出的文本結果)
    頁面上錄制音頻並發送

實現功能及技術要點

1、基於WebSocket實現對話流
頁面與后端的交互是實時互動的,所以采用WebSocket協議,而不是HTTP請求,這樣后端推送回的消息可以實時顯示在頁面上。
WebSocket的返回是隊列的、無序的,在后續處理中我們也需要注意這一點,在后文中會說到。
2、調用設備麥克風進行音頻錄制和轉碼加頭,基於WebAudio、WaveSurferJS等實現音頻處理和繪制
3、基於Vue的響應式頁面實現
4、CSS3 + Canvas + JS 交互效果優化

  • 錄制音頻CSS動畫效果
  • 聊天記錄自動滾動
    下面給出部分實現代碼。

集成WebSocket

我們的聊天組件是頁面側邊打開的抽屜(el-drawer),Vue組件會在打開時創建,關閉時銷毀。在組件中引入WebSocket,並管理它的開、關、消息接收和發送,使它的生命周期與組件一致(打開窗口時創建ws連接,關閉窗口時關閉連接,避免與后台連接過多。)

created(){
   if (typeof WebSocket === 'undefined') {
      alert('您的瀏覽器不支持socket')
    } else {
      // 實例化socket
      this.socket = new WebSocket(this.socketServerPath)
      // 監聽socket連接
      this.socket.onopen = this.open
      // 監聽socket錯誤信息
      this.socket.onerror = this.error
      // 監聽socket消息
      this.socket.onmessage = this.onMessage
      this.socket.onclose = this.close
    }
}
destroyed(){
  this.socket.close()
}

如上,將WebSocket的事件綁定到JS方法中,可以在對應方法中實現對數據的接收和發送。
打開瀏覽器控制台,選中指定的標簽,便於對WebSocket連接進行監控和查看。
在c

音頻錄制采集

從瀏覽器端音頻和視頻采集基於網頁即時通信(Web Real-Time
Communication,簡稱WebRTC) 的API。通過WebRTCgetUserMedia實現,獲取一個MediaStream對象,將該對象關聯到AudioContext即可獲得音頻。

可參考RecorderJS的實現: https://github.com/mattdiamond/Recorderjs/blob/master/examples/example_simple_exportwav.html



if (navigator.getUserMedia) {
      navigator.getUserMedia(
        { audio: true }, // 只啟用音頻
        function(stream) {
          var context = new(window.webkitAudioContext || window.AudioContext)()
          var audioInput = context.createMediaStreamSource(stream)
          var recorder = new Recorder(audioInput)

        },
        function(error) {
          switch (error.code || error.name) {
            case 'PERMISSION_DENIED':
            case 'PermissionDeniedError':
              throwError('用戶拒絕提供信息。')
              break
            case 'NOT_SUPPORTED_ERROR':
            case 'NotSupportedError':
              throwError('瀏覽器不支持硬件設備。')
              break
            case 'MANDATORY_UNSATISFIED_ERROR':
            case 'MandatoryUnsatisfiedError':
              throwError('無法發現指定的硬件設備。')
              break
            default:
              throwError('無法打開麥克風。異常信息:' + (error.code || error.name))
              break
          }
        }
      )
    } else {
      throwError('當前瀏覽器不支持錄音功能。')
    }

注意: 若navigator.getUserMedia獲取到的是undefined,是Chrome瀏覽器的安全策略導致的,需要通過https請求或配置瀏覽器,配置地址: chrome://flags/#unsafely-treat-insecure-origin-as-secure

瀏覽器采集到的音頻為PCM格式(PCM (脈沖編碼調制 Pulse Code Modulation)),需要對音頻加頭才能在頁面上進行播放。注意加頭時采樣率、采樣頻率、聲道數量等必須與采樣時相同,不然加完頭后的音頻無法解碼。參考查看https://github.com/mattdiamond/Recorderjs/blob/master/src/recorder.js中exportWav方法。

業務中對接的語音識別引擎為實時轉寫引擎,即:不是錄制完成后再發送,而是一邊錄制一邊進行編碼並發送。
使用onaudioprocess方法監聽語音的輸入:
RecorderJS onaudioprocess方法

參考這個實現,我們可以在每次監聽到有數據寫入時,從buffer中獲取到錄制到的數據,並進行編碼、壓縮,再通過WebSocket發送。

Vue組件設計和業務實現

分析頁面業務邏輯,將代碼拆分成兩個組件:
ChatDialog.vue 聊天對話框頁面,根據輸入類型,分為文本輸入、語音輸入。
ChatRecord.vue聊天記錄組件,根據發送方(自己或者系統)展示向左/向右的氣泡,根據內容顯示文本、音頻等。ChatDialogChatRecord的父組件,遍歷ChatDialog中的chatList對象(Array),將chatList中的項注入到ChatRecord中。

<div class="chat-list">
            <div v-for="(item,index) in chatList" :key="index" class="msg-wrapper">
                <chat-record ref="chatRecord" :data="item" @showJson="showJsonDialog"></chat-record>
            </div>
            <div id="msg_end" style="height:0px; overflow:hidden"></div>
        </div>
</div>

對於聊天記錄的氣泡展示,與數據類型相關性很強,ChatRecord組件只關心對數據的處理和展示,我們可以完全不用關心消息的發送、接收、音頻的錄制、停止錄制、接受音頻等邏輯,只需要根據數據來展示不同的樣式即可。
這樣Vue的響應式就充分獲得了用武之地:無需用代碼對樣式展示進行控制,只需要設計合理的數據格式和樣式模板,然后注入不同的數據即可。
模板頁面: 使用v-if控制,修改chatList里的對象內容即可改變頁面展示。

根據業務需求,將ChatRecord可能接收到的數據分為以下幾類:

發送方為自己:

計時器使用JS的setInterval方法,每100ms更新一次錄制時長

 this.recordTimer = setInterval(() => {
        this.audioDuration = this.audioDuration + 0.1
      }, 100)

停止后清空計時器:

 clearInterval(this.recordTimer)
  • 語音輸入完畢,根據錄制的語音,繪制波紋
    效果:
    繪制出真實的波形

使用wavesurfer插件:

 initWaveSurfer() {
      this.$nextTick(() => {
        this.wavesurfer = WaveSurfer.create({
          container: this.$refs.waveform,
          height: 20,
          waveColor: '#3d6fff',
          progressColor: 'blue',
          backend: 'MediaElement',
          mediaControls: false,
          audioRate: '1',
          fillParent: false,
          maxCanvasWidth: 500,
          barWidth: 1,
          barGap: 2,
          barHeight: 5,
          barMinHeight: 3,
          normalize: true,
          cursorColor: '#409EFF'
        })
        this.convertAudioToUrl(this.waveAudio).then((res) => {
          this.wavesurfer.load(res)

          setTimeout(() => {
            this.audioDuration = this.getAudioDuration()
          }, 100)
        })
      })
    },

   // 將音頻轉化成url地址
    convertAudioToUrl(audio) {
      let blobUrl = ''
      if (this.data.sendBy === 'self') {
        blobUrl = window.URL.createObjectURL(audio)
        return new Promise((resolve) => {
          resolve(blobUrl)
        })
      } else {
        return this.base64ToBlob({
          b64data: audio,
          contentType: 'audio/wav'
        })
      }
    },

    base64ToBlob({ b64data = '', contentType = '', sliceSize = 512 } = {}) {
      return new Promise((resolve, reject) => {
        // 使用 atob() 方法將數據解碼
        let byteCharacters = atob(b64data)
        let byteArrays = []
        for (
          let offset = 0;
          offset < byteCharacters.length;
          offset += sliceSize
        ) {
          let slice = byteCharacters.slice(offset, offset + sliceSize)
          let byteNumbers = []
          for (let i = 0; i < slice.length; i++) {
            byteNumbers.push(slice.charCodeAt(i))
          }
          // 8 位無符號整數值的類型化數組。內容將初始化為 0。
          // 如果無法分配請求數目的字節,則將引發異常。
          byteArrays.push(new Uint8Array(byteNumbers))
        }
        let result = new Blob(byteArrays, {
          type: contentType
        })
        result = Object.assign(result, {
          // 這里一定要處理一下 URL.createObjectURL
          preview: URL.createObjectURL(result),
          name: `XXX.wav`
        })
        resolve(window.URL.createObjectURL(result))
      })
    },

發送方為系統:

  • 僅返回文本:顯示文本

  • 僅返回音頻(參考發送方為自己的實現)
    繪制波形

  • 返回文本,隨即返回文本對應的合成音頻,顯示文本和播放按鈕
    狀態,顯示播放按鈕

播放狀態,顯示暫停按鈕

頁面嵌入audio標簽,將hidden設置為true使其不顯示:

<div class="audio-player">
          <svg-icon v-if="!isPlaying" icon-class='play' @click="onClickAudioPlayer" />
          <svg-icon v-else icon-class='pause' @click="onClickAudioPlayer" />
          <audio :src="playAudioUrl" autostart="true" hidden="true" ref="audioPlayer" />
        </div>

playAudioUrl的生成參考上面生成的wavesurfer的url。
使用isPlaying參數記錄當前音頻的播放狀態,並使用setTimeout方法,當播放了音頻時長后,將播放按鈕自動置為play

  onClickAudioPlayer() {
      if (this.isPlaying) {
        this.$refs.audioPlayer.pause()
        this.isPlaying = false
      } else {
        // 每次點擊時,開始播放,並在播放完畢將isPlaying置為false
        this.$refs.audioPlayer.currentTime = 0
        this.$refs.audioPlayer.play()
        this.isPlaying = true

        setTimeout(() => {
          // 將正在播放重置為false
          this.isPlaying = false
        }, Math.ceil(this.$refs.audioPlayer.duration) * 1000)
      }
    },
  • 聊天記錄自動定位到最后一條:
    使用scrollIntoView()方法
  • 記錄每次會話對應的記錄ID(recordId):
    定義單次會話的id,並在返回的消息中回傳,從而建立多條websocket返回的關聯關系。

以上就是全部實現。難點主要是請求麥克風權限和對音頻進行編碼,在加wav頭時必須保證和采樣時的采樣率、頻率一致


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM