先看效果:
智谱新上线的音视频通话大模型拥有理解音频和画面的能力,接入该api,需要使用webSocket协议不断传输音频和视频的base64编码。
WebRTC(Web Real-Time Communication)是一个支持网页浏览器进行实时语音对话或视频对话的技术,webRTC最强大的功能是无需服务器,可以实现浏览器端对端的音视频通话,这里只使用了webRTC实现视频画面显示。
这里就详细说一下从开发到跑起这个demo踩的一些不得不说的血泪坑。
一
技术栈使用vue2,webRTC自然就选用了vue-webRTC,npm地址:https://www.npmjs.com/package/vue-webrtc/v/2.0.0?activeTab=readme
通过看它的readme文档发现,最新版是3.0.1,从3.0.0开始支持vue3,vue2使用的是2.0.0版本。
跑vue-webRTC这个示例没有出现问题,但是上线的时候却发现一直报错:Permissions policy violation: camera is not allowed in this document.
网上有关这些的文档都是说没有授权导致,在这里查资料卡了很久。
线上使用的也是https协议,也授权了权限,调试很多次一直报错,终于在一篇文档里发现了问题: 【Web安全】Permissions Policy(权限策略)详解
不知道是谁给线上环境的响应头里设置了不允许使用麦克风和摄像头,也是吐血了。
这里需要从Nginx修改配置,于是换了一个线上环境,成功解决。
二
智谱接入模型需要使用webSocket协议,这很简单,使用原生js new WebSocket(url)即可,提供了.send()方法发送数据,.onopen方法监听建立连接成功 .onmessage方法监听服务器返回。
部分代码如下:
this.ws = new WebSocket(this.socketURL + `?Authorization=${APIKey}`)
this.ws.onopen = () => {
console.log('WebSocket 连接已打开')
}
this.ws.onmessage = event => {
let data = JSON.parse(event.data)
console.log('收到服务器消息:', data)
}
// 发送消息
this.ws.send(JSON.stringify(message))
但是接入的时候需要传入apiKey进行鉴权,api文档里写的是将key放在Header头里面,这完全就没有考虑浏览器调用情况!
浏览器端对写入header头很严格,而且这也不是简单的get post请求,可以用axios工具设置请求头。
所以当时有两种方法,1、找一个可以支持websocket协议写入请求头的库 2、使用@fortaine/fetch-event-source发送fetch请求,fetch是websocket的替代方案,可以解决传复杂参数问题。
因为有一个已经封装好了的可以用fetch的库,所以当时选择通过fetch协议调用。
通过一番操作以后,发post请求报405 发get请求报400,调试到这里真是吐血了!!用文档给的java代码调用就没有问题,这个文档根本就没有考虑浏览器方案。
服务器端只支持websocket协议,而websocket或eventsource是get请求,带参只能跟在url地址后面。所以发post会报405请求方法不对,后端又只从请求头里拿鉴权信息,发get带过去的它都不认。
经过一番协商,后端改了鉴权方法,从url地址里拿key信息,这下直接用原生webSocket协议都行了。
调试半天,后端改一下协议十分钟搞定。
三
搞定传输以后,剩下的就比较简单了,从音视频流里获取视频和音频数据,再转成base64编码,直接发送即可。
特别感谢:https://github.com/2fps/recorder
作者整理的关于音频的资料很详细。
请求获取音视频流:
// 获得音视频数据流
getUserMedia() {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({
audio: true, // 设置为 true 以请求音频流
video: true
})
.then(stream => {
// 拿到音视频数据流
this.activeStream = stream
// 包含视频&&音频
this.videoStream = new MediaStream(stream)
// 创建只包含视频轨道的 MediaStream
// this.videoStream = new MediaStream()
// this.activeStream.getVideoTracks().forEach(track => this.videoStream.addTrack(track))
// 创建只包含音频轨道的 MediaStream
this.audioStream = new MediaStream()
this.activeStream.getAudioTracks().forEach(track => this.audioStream.addTrack(track))
// 获得音频数据流
this.getAudioBlob()
this.getVideoBlob()
})
.catch(err => {
console.error('获取用户媒体流失败:', err)
})
} else {
alert('你的浏览器不支持 getUserMedia')
}
},
获得音频流数据
// 获得音频数据流
getAudioBlob() {
// 获得pcm格式的音频数据
// 创建 AudioContext
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
// 创建 MediaStreamSource
const source = audioContext.createMediaStreamSource(this.audioStream)
// 创建录音节点,指定缓冲区大小和处理音频的输入输出通道数
const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1)
// 将音频输入源连接到录音节点,并将录音节点连接到音频上下文的输出
source.connect(scriptProcessor)
scriptProcessor.connect(audioContext.destination)
// 存储 scriptProcessor 和 audioContext 以便之后可以断开连接
this.scriptProcessor = scriptProcessor
this.audioContext = audioContext
// 当音频数据可用时执行的回调函数
scriptProcessor.onaudioprocess = audioProcessingEvent => {
const inputBuffer = audioProcessingEvent.inputBuffer
// 获取第一个(也是唯一一个)输入通道的 Float32Array 数据 leftChannelData 就是 PCM 数据
const leftChannelData = inputBuffer.getChannelData(0)
this.inputAudioData.push(new Float32Array(leftChannelData))
this.audioSize += leftChannelData.length
}
},
获得视频流数据
// 获得视频数据流
getVideoBlob() {
// 设置 MediaRecorder 选项
const videoOptions = { mimeType: 'video/webm;codecs=H264' }
// 创建视频 MediaRecorder
this.videoMediaRecorder = new MediaRecorder(this.videoStream, videoOptions)
// 当stop时触发
this.videoMediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) {
const data = event.data
// 获得音视频的Blob对象
const blobDate = this.getBlob(data)
if (this.control.vad_config.server_vad) {
// 获得base64数据
this.getBase64(blobDate.videoBlob, blobDate.audioBlob)
} else {
// 存储blob对象 等待手动发送
this.audioBlobs.push(blobDate.audioBlob)
this.videoBlobs.push(blobDate.videoBlob)
}
}
}
// 当录制停止时触发
this.videoMediaRecorder.onstop = () => {
// 开启vad检测时 触发开启事件
if (this.control.vad_config.server_vad) {
this.videoMediaRecorder.start()
}
}
},
在这里也要说一点this.videoMediaRecorder.start()开启的时候可以加参数 即多少ms触发一次ondataavailable回调,但是这样在ondataavailable里面拿到的数据只有第一次有头部,后面触发获得的没有头部,需要把所有数据段的拼接起来才能组成完整的视频片段。
所有这里采用加一个定时器定时触发stop,stop也会触发ondataavailable,并且监听stop的触发,stop停止时就开启,这样拿到的是完整的视频片段。
获得音视频数据后,下面再转成base64即可,下面是互相转换的函数:
/**
* 将Blob对象转换为数组。
*
* @param {Blob} blob - 要转换的Blob对象。
* @returns {Promise<Uint8Array>} 返回一个Promise,该Promise解析为一个包含二进制数据的Uint8Array数组。
*/
export function blobToBinaryArray(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function () {
const arrayBuffer = reader.result
const bytes = new Uint8Array(arrayBuffer)
resolve(bytes)
}
reader.onerror = function (error) {
reject(error)
}
reader.readAsArrayBuffer(blob)
})
}
/**
* 将Blob对象转换为Base64编码的字符串。
* @param {Blob} blob - 要转换的Blob对象。
* @returns {Promise<string>} 返回一个Promise,解析为Base64编码的字符串。
*/
export function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = function () {
const base64String = reader.result.split(',')[1]
resolve(base64String)
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
/**
* 将Base64编码的字符串转换为Blob对象。
* @param {string} base64 - 需要转换的Base64编码字符串
* @param {string} mimeType - Blob对象的MIME类型,例如'image/jpeg'或'image/png'。
* @returns {Blob} 返回一个Blob对象
*/
export function base64ToBlob(base64, mimeType) {
// 分割Base64字符串以获取数据部分,通常Base64字符串以"data:image/...,"开头
const byteString = atob(base64)
// 创建一个ArrayBuffer,其大小与解码后的字符串长度相同
const ab = new ArrayBuffer(byteString.length)
// 创建一个Uint8Array视图,将ArrayBuffer的内容初始化为8位无符号整数值
const ia = new Uint8Array(ab)
// 遍历解码后的字符串,并将每个字符的Unicode编码存入Uint8Array数组
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
// 使用ArrayBuffer和MIME类型创建Blob对象
const blob = new Blob([ab], { type: mimeType })
// 返回Blob对象
return blob
}
/**
* 将Base64编码的字符串转换为ArrayBuffer。
* @param {string} base64 - 需要转换的Base64编码字符串。
* @returns {ArrayBuffer} 返回一个ArrayBuffer对象,它包含了从Base64字符串转换而来的字节数据。
*/
export function base64ToArrayBuffer(base64) {
const binaryString = window.atob(base64) // 使用window.atob()方法将Base64编码的字符串解码成二进制字符串
const len = binaryString.length // 获取二进制字符串的长度
const bytes = new Uint8Array(len) // 创建一个Uint8Array数组,用于存储转换后的字节数据
for (let i = 0; i < len; i++) {
// 遍历二进制字符串的每个字符
bytes[i] = binaryString.charCodeAt(i) // 将每个字符的charCodeAt值(即字符的Unicode编码)存储到Uint8Array数组中
}
return bytes.buffer // 返回Uint8Array数组的buffer属性,它是一个ArrayBuffer对象
}
四
该api会有两种返回方式:1、返回文本 2、默认返回音频
这样就比较难受了,不能同时展示,而且模型返回的音频是带感情的,还不能简单的文字转音频。
现在常见的音频转文本方案都是调用外部api,或者在浏览器端运行转文本模型(这个试了一下它们的样例,不仅要翻墙,一百多兆的模型转的文本错字也很多)
最后放弃了,写了一个文本和语音播放,返回文本就展示文本,返回语音就展示语音。
语音返回的是pcm或wav格式的base64字符串,将base64字符串转成blob对象,创建一个资源url,写进audio.src里播放即可,这里比较简单,详见代码:
// 播放音频流数据
outAudios(serverMessagesIndex) {
let nextAudioMessage = this.serverMessages[serverMessagesIndex]
// 设置播放动画
for (let i = 0; i < this.serverMessages.length; i++) {
this.serverMessages[i].voicePlay = false
}
this.serverMessages[serverMessagesIndex].voicePlay = true
console.log(nextAudioMessage)
if (this.serverMessages.length < 1) {
return
}
let audioContent = nextAudioMessage.audioContent
let audioBase64 = ''
let wavBlobs = []
for (let i = 0; i < audioContent.length; i++) {
// 服务端返回的是base64字符串
audioBase64 = audioContent[i]
// wav Blob对象
const wavBlob = base64ToBlob(audioBase64, 'audio/mav')
wavBlobs.push(wavBlob)
}
// download(wavBlobs[0], 'wav')
// 播放音频
this.playNextAudio(wavBlobs, 0, serverMessagesIndex)
},
// 播放音频的函数
playNextAudio(wavBlobs, currentAudioIndex, serverMessagesIndex) {
if (currentAudioIndex < wavBlobs.length) {
// 创建音频URL
let audioSrc = URL.createObjectURL(
// new Blob([wavBlobs[currentAudioIndex]], { type: 'audio/wav' })
wavBlobs[currentAudioIndex]
)
// 设置音频源并播放
this.audioElement.src = audioSrc
this.audioElement.play()
// 当音频播放完毕
this.audioElement.onended = () => {
console.log(audioSrc, '音频播放完毕:')
if (
!isNaN(this.audioElement.duration) &&
this.serverMessages[serverMessagesIndex].isDuration
) {
// 加入长度
this.serverMessages[serverMessagesIndex].audioDuration += this.audioElement.duration
this.serverMessages[serverMessagesIndex].audioDuration =
+this.serverMessages[serverMessagesIndex].audioDuration.toFixed(0)
}
// 释放创建的URL对象
URL.revokeObjectURL(audioSrc)
// 更新索引以播放下一个音频
currentAudioIndex++
// 播放下一个音频
this.playNextAudio(wavBlobs, currentAudioIndex, serverMessagesIndex)
}
} else {
console.log('所有音频播放完毕')
this.serverMessages[serverMessagesIndex].voicePlay = false
this.serverMessages[serverMessagesIndex].isDuration = false
return -1
}
},
大功告成!
五 参考资料
WebRTC本地实现 - WebRTC通信实现(Web端)
【Web安全】Permissions Policy(权限策略)详解
实时音视频WebRTC常见问题汇总
Vue使用js-audio-recorder实现录制,播放与下载音频功能
WebRTC 从实战到未来!
评论