引言
在與實現了語音合成、語義分析、機器翻譯等演算法的後端互動時,頁面可以設計成更為人性化、親切的方式。我們採用類似於聊天對話的實現,效果如下:
- 智慧客服(輸入文字,返回引擎處理後的文字結果)
-
語音合成(輸入文字,返回文字以及合成的音訊)
如上圖所示,返回文字後,再返回合成出的音訊。
音訊按鈕嵌在對話氣泡中,可以點選播放。 -
語音識別(在頁面錄製語音傳送,頁面實時展示識別出的文字結果)
實現功能及技術要點
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
連線進行監控和檢視。
音訊錄製採集
從瀏覽器端音訊和視訊採集基於網頁即時通訊(Web Real-Time
Communication,簡稱WebRTC
) 的API。通過WebRTC
的getUserMedia
實現,獲取一個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
方法監聽語音的輸入:
參考這個實現,我們可以在每次監聽到有資料寫入時,從buffer中獲取到錄製到的資料,並進行編碼、壓縮,再通過WebSocket傳送。
Vue元件設計和業務實現
分析頁面業務邏輯,將程式碼拆分成兩個元件:
ChatDialog.vue
聊天對話方塊頁面,根據輸入型別,分為文字輸入、語音輸入。
ChatRecord.vue
聊天記錄元件,根據傳送方(自己或者系統)展示向左/向右的氣泡,根據內容顯示文字、音訊等。ChatDialog
是ChatRecord
的父元件,遍歷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
可能接收到的資料分為以下幾類:
傳送方為自己:
- 文字輸入,顯示文字
實現簡單,不做贅述。 - 語音輸入 Loading狀態,顯示波紋動畫和計時
該動畫使用CSS實現,參考地址: https://www.cnblogs.com/lhb25/p/loading-spinners-animated-with-css3.html
計時器使用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頭時必須保證和取樣時的取樣率、頻率一致 。