Vue +WebSocket + WaveSurferJS 實現(xiàn)H5聊天對話交互

引言

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

  • 智能客服(輸入文本,返回引擎處理后的文本結(jié)果)
與智能客服對話
  • 語音合成(輸入文本,返回文本以及合成的音頻)

    語音合成

    如上圖所示婚脱,返回文本后吟宦,再返回合成出的音頻蜗侈。
    音頻按鈕嵌在對話氣泡中薄湿,可以點擊播放吆倦。

  • 語音識別(在頁面錄制語音發(fā)送,頁面實時展示識別出的文本結(jié)果)

    頁面上錄制音頻并發(fā)送

實現(xiàn)功能及技術(shù)要點

1泛领、基于WebSocket實現(xiàn)對話流
頁面與后端的交互是實時互動的,所以采用WebSocket協(xié)議渊鞋,而不是HTTP請求绰更,這樣后端推送回的消息可以實時顯示在頁面上瞧挤。
WebSocket的返回是隊列的、無序的儡湾,在后續(xù)處理中我們也需要注意這一點特恬,在后文中會說到。
2徐钠、調(diào)用設(shè)備麥克風(fēng)進行音頻錄制和轉(zhuǎn)碼加頭癌刽,基于WebAudio、WaveSurferJS等實現(xiàn)音頻處理和繪制
3丹皱、基于Vue的響應(yīng)式頁面實現(xiàn)
4妒穴、CSS3 + Canvas + JS 交互效果優(yōu)化

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

集成WebSocket

我們的聊天組件是頁面?zhèn)冗叴蜷_的抽屜(el-drawer)摊崭,Vue組件會在打開時創(chuàng)建讼油,關(guān)閉時銷毀。在組件中引入WebSocket呢簸,并管理它的開矮台、關(guān)、消息接收和發(fā)送根时,使它的生命周期與組件一致(打開窗口時創(chuàng)建ws連接瘦赫,關(guān)閉窗口時關(guān)閉連接,避免與后臺連接過多蛤迎。)

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

如上确虱,將WebSocket的事件綁定到JS方法中,可以在對應(yīng)方法中實現(xiàn)對數(shù)據(jù)的接收和發(fā)送替裆。
打開瀏覽器控制臺校辩,選中指定的標簽,便于對WebSocket連接進行監(jiān)控和查看辆童。

在c

音頻錄制采集

從瀏覽器端音頻和視頻采集基于網(wǎng)頁即時通信(Web Real-Time
Communication宜咒,簡稱WebRTC) 的API。通過WebRTCgetUserMedia實現(xiàn)把鉴,獲取一個MediaStream對象故黑,將該對象關(guān)聯(lián)到AudioContext即可獲得音頻。

可參考RecorderJS的實現(xiàn): 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('瀏覽器不支持硬件設(shè)備场晶。')
              break
            case 'MANDATORY_UNSATISFIED_ERROR':
            case 'MandatoryUnsatisfiedError':
              throwError('無法發(fā)現(xiàn)指定的硬件設(shè)備。')
              break
            default:
              throwError('無法打開麥克風(fēng)怠缸。異常信息:' + (error.code || error.name))
              break
          }
        }
      )
    } else {
      throwError('當(dāng)前瀏覽器不支持錄音功能峰搪。')
    }

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

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

業(yè)務(wù)中對接的語音識別引擎為實時轉(zhuǎn)寫引擎,即:不是錄制完成后再發(fā)送夯尽,而是一邊錄制一邊進行編碼并發(fā)送瞧壮。
使用onaudioprocess方法監(jiān)聽語音的輸入:

RecorderJS onaudioprocess方法

參考這個實現(xiàn),我們可以在每次監(jiān)聽到有數(shù)據(jù)寫入時匙握,從buffer中獲取到錄制到的數(shù)據(jù)咆槽,并進行編碼、壓縮圈纺,再通過WebSocket發(fā)送秦忿。

Vue組件設(shè)計和業(yè)務(wù)實現(xiàn)

分析頁面業(yè)務(wù)邏輯,將代碼拆分成兩個組件:
ChatDialog.vue 聊天對話框頁面蛾娶,根據(jù)輸入類型灯谣,分為文本輸入、語音輸入蛔琅。
ChatRecord.vue聊天記錄組件胎许,根據(jù)發(fā)送方(自己或者系統(tǒng))展示向左/向右的氣泡,根據(jù)內(nèi)容顯示文本罗售、音頻等辜窑。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>

對于聊天記錄的氣泡展示切距,與數(shù)據(jù)類型相關(guān)性很強朽缎,ChatRecord組件只關(guān)心對數(shù)據(jù)的處理和展示,我們可以完全不用關(guān)心消息的發(fā)送谜悟、接收话肖、音頻的錄制、停止錄制葡幸、接受音頻等邏輯最筒,只需要根據(jù)數(shù)據(jù)來展示不同的樣式即可。
這樣Vue的響應(yīng)式就充分獲得了用武之地:無需用代碼對樣式展示進行控制蔚叨,只需要設(shè)計合理的數(shù)據(jù)格式和樣式模板床蜘,然后注入不同的數(shù)據(jù)即可辙培。
模板頁面: 使用v-if控制,修改chatList里的對象內(nèi)容即可改變頁面展示邢锯。

根據(jù)業(yè)務(wù)需求扬蕊,將ChatRecord可能接收到的數(shù)據(jù)分為以下幾類:

發(fā)送方為自己:

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

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

停止后清空計時器:

 clearInterval(this.recordTimer)
  • 語音輸入完畢,根據(jù)錄制的語音护戳,繪制波紋
    效果:


    繪制出真實的波形

使用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)
        })
      })
    },

   // 將音頻轉(zhuǎn)化成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() 方法將數(shù)據(jù)解碼
        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 位無符號整數(shù)值的類型化數(shù)組翎冲。內(nèi)容將初始化為 0。
          // 如果無法分配請求數(shù)目的字節(jié)灸异,則將引發(fā)異常府适。
          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))
      })
    },

發(fā)送方為系統(tǒng):

  • 僅返回文本:顯示文本

  • 僅返回音頻(參考發(fā)送方為自己的實現(xiàn))


    繪制波形
  • 返回文本,隨即返回文本對應(yīng)的合成音頻肺樟,顯示文本和播放按鈕


    狀態(tài)檐春,顯示播放按鈕
播放狀態(tài),顯示暫停按鈕

頁面嵌入audio標簽么伯,將hidden設(shè)置為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參數(shù)記錄當(dāng)前音頻的播放狀態(tài),并使用setTimeout方法田柔,當(dāng)播放了音頻時長后俐巴,將播放按鈕自動置為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()方法
  • 記錄每次會話對應(yīng)的記錄ID(recordId):
    定義單次會話的id,并在返回的消息中回傳缀磕,從而建立多條websocket返回的關(guān)聯(lián)關(guān)系缘圈。

以上就是全部實現(xiàn)。難點主要是請求麥克風(fēng)權(quán)限和對音頻進行編碼袜蚕,在加wav頭時必須保證和采樣時的采樣率糟把、頻率一致

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末牲剃,一起剝皮案震驚了整個濱河市遣疯,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌凿傅,老刑警劉巖缠犀,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件数苫,死亡現(xiàn)場離奇詭異,居然都是意外死亡辨液,警方通過查閱死者的電腦和手機文判,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來室梅,“玉大人戏仓,你說我怎么就攤上這事⊥鍪螅” “怎么了赏殃?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長间涵。 經(jīng)常有香客問我仁热,道長,這世上最難降的妖魔是什么勾哩? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任抗蠢,我火速辦了婚禮,結(jié)果婚禮上思劳,老公的妹妹穿的比我還像新娘迅矛。我一直安慰自己,他們只是感情好潜叛,可當(dāng)我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布秽褒。 她就那樣靜靜地躺著,像睡著了一般威兜。 火紅的嫁衣襯著肌膚如雪销斟。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天椒舵,我揣著相機與錄音蚂踊,去河邊找鬼。 笑死笔宿,一個胖子當(dāng)著我的面吹牛犁钟,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播措伐,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼特纤,長吁一口氣:“原來是場噩夢啊……” “哼军俊!你這毒婦竟也來了侥加?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤粪躬,失蹤者是張志新(化名)和其女友劉穎担败,沒想到半個月后昔穴,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡提前,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年吗货,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片狈网。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡宙搬,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出拓哺,到底是詐尸還是另有隱情勇垛,我是刑警寧澤,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布士鸥,位于F島的核電站闲孤,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏烤礁。R本人自食惡果不足惜讼积,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望脚仔。 院中可真熱鬧勤众,春花似錦、人聲如沸鲤脏。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽凑兰。三九已至掌桩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間姑食,已是汗流浹背波岛。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留音半,地道東北人则拷。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像曹鸠,于是被迫代替她去往敵國和親煌茬。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,786評論 2 345