uni-app 连接腾讯云实时语音识别 (安卓),小程序和h5改吧改吧也能用
步骤
- 将传入的长文本,截取分片发送给后端,让后端生成签名,请求地址 (短文本的话可以一次性发送)
- 从后端获取签名,因为签名包含 “SecretId” “appId” 等信息,所以由后端生成比较安全
- 用后端返回的签名去和腾讯云建立 WebScoket 连接,然后将腾讯云一次返回的数据(ArrayBuffer形式存储的数据)整合到一起
- 将一次返回的数据存储到本地里面 (因为uni-app 播放语音的api 只支持传入本地连接或者线上连接,所以并不能直接使用音频数据播放,并且uni-app 的 uni.uploadFile api存储后返回的临时地址,并不能直接播放),这里使用的是安卓原生的方法,保存的,小程序的或 h5 可以使用其他方法,也会简单很多
- 然后等待上一次保存成功后,进行下一次保存,这里会进行阻塞等待(短文本的话不需要去循环,保存一次就可以了)
- 存储成功后获取到地址,将文件地址存储到一个数组中,以便将来播放
- 使用 uni.createInnerAudioContext api 播放音频
这里播放音频和 获取音频数据是独立的,一是为了减少用户等待的时间,二是防止每次一小段播放完毕后再去请求存储本次播放的音频文件,可能会导致每段话中间停顿太久,所以提前存储到数组,到时候直接去遍历播放,边播放边转换
好了,话不多说,直接上代码,详细的解释都写在注释里面了,可以直接看注释(注释写的很详细哦~)
import request from '@/utils/request.js'
class TTSPlayer {
constructor() {
this.ttsUrl = '', // url签名
this.textArr = [], // 文本数组
this.audioBlob = null, // 单次循环存储 ArrayBuffer
this.filePath = '', // 文件路径
this.isFrist = true, // 是否是第一次播放
this.isOver = false, // 本地文件是否已经存储到本地
this.fileList = [], // 存储到本地的地址,数组
this.fileIndex = 0, // 当前播放到的地址索引 fileList手动索引
this.overPlayer = false // 是否已经播放完了
}
// 开始播放
// info 里面除了 text,其他都是获取签名的参数
async start(info) {
console.log(info.reply)
console.log(info)
// 分割文本
this.textArr = splitTextIntoSubtexts(info.text)
this.overPlayer = false
for (let i = 0; i < this.textArr.length; i++) {
this.isOver = false
this.connectScoket(this.textArr[i], i, info)
// 每次阻塞,等待音频存储完毕后进行下一次
await this.waitFor(true)
}
}
// 等待播放完毕,调用方法,进行一些操作
async onOverPlayer(callback) {
await this.waitFor()
callback()
}
// 等待阻塞 上次语音转换完毕
waitFor(flag = false) {
return new Promise(resolve => {
const checkHasAdd = setInterval(() => {
if (this.isOver === true && flag) {
clearInterval(checkHasAdd);
resolve();
}
if (this.overPlayer === true && !flag) {
clearInterval(checkHasAdd);
resolve();
}
}, 100);
});
}
inFile(blob, index) {
// this.removeFile()
// 使用plus.io将Blob写入本地文件
return new Promise((resolve, reject) => {
// 这里使用 安卓原生的方式 PRIVATE_DOC (操作私有文档目录)
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
// 传入操作文件路径,这里只传一级目录的话就直接存储在根目录下
// create: true 没有该目录的话,直接穿件新的,有的话就覆盖掉之前的
// 能看到我这里并没有删除文件,因为我这里是语音对话模型,所以采取直接覆盖的形式
fs.root.getFile(`_doc/audio/test${index}.mp3`, {
create: true
}, (fileEntry) => {
fileEntry.createWriter((writer) => {
// 存储文件成功后的回调
writer.onwriteend = () => {
// 获取文件路径
this.filePath = fileEntry.toLocalURL();
this.fileList.push(this.filePath)
console.log(this.fileList)
resolve(this.filePath)
this.audioBlob = null
// 存储完毕
this.isOver = true
console.log('文件路径:', this.filePath)
console.log('Blob文件已成功写入本地文件');
};
writer.onerror = (e) => {
console.error('写入本地文件时发生错误:', e);
};
console.log(blob)
// 将ArrayBuffer 转成 base64,并且存储进本地中
const base64 = uni.arrayBufferToBase64(blob)
// writer.write(blob);
console.log('base64', base64)
writer.writeAsBinary(base64)
}, (e) => {
console.error('创建写入器时发生错误:', e);
});
}, (e) => {
console.error('获取文件条目时发生错误:', e);
});
}, (e) => {
console.error('请求文件系统时发生错误:', e);
});
})
}
// 播放音频
async palyAudio(blob) {
const innerAudioContext = uni.createInnerAudioContext();
innerAudioContext.autoplay = true;
innerAudioContext.src = this.fileList[this.fileIndex];
this.isFrist = false
innerAudioContext.onPlay(() => {
console.log('开始播放');
});
innerAudioContext.onEnded(() => {
// 判断是否是最后一次播放
if(this.fileIndex === this.textArr.length - 1) {
this.overPlayer = true
this.fileIndex = 0
}
console.log('fileIndex', this.fileIndex)
this.fileIndex++
// 每次播放完销毁播放实例,因为安卓如果开启太多播放器可能会导致播放不出声音
innerAudioContext.destroy()
// 重新递归,去播放第二段音频
setTimeout(() => {
if(this.fileIndex <= this.textArr.length - 1 && !this.overPlayer) {
this.palyAudio()
}
}, 1)
})
}
concatArrayBuffer(buffer1, buffer2) {
// 创建一个新的 ArrayBuffer,长度为两个输入缓冲区的总长度
const mergedBuffer = new ArrayBuffer(buffer1.byteLength + buffer2.byteLength);
// 创建视图以便操作缓冲区
const mergedView = new Uint8Array(mergedBuffer);
const view1 = new Uint8Array(buffer1);
const view2 = new Uint8Array(buffer2);
// 将第一个缓冲区的数据复制到合并后的缓冲区
mergedView.set(view1, 0);
// 将第二个缓冲区的数据复制到合并后的缓冲区
mergedView.set(view2, view1.length);
return mergedBuffer;
}
// 连接 webscoket 连接
async connectScoket(text, index, info) {
console.log('text', text)
await this.getUrl(text, info)
if (!this.ttsUrl) {
return
}
uni.connectSocket({
url: this.ttsUrl
})
uni.onSocketOpen(() => {
console.log('连接已经打开')
})
uni.onSocketError((res) => {
console.log('WebSocket连接打开失败,请检查!');
})
uni.onSocketMessage(async (res) => {
console.log('接受到消息:', res)
if (res.data instanceof ArrayBuffer) {
// 如果不是第一次返回就将 两个 ArrayBuffer 合并到一起
if (!this.audioBlob) {
this.audioBlob = res.data
} else {
this.audioBlob = this.concatArrayBuffer(this.audioBlob, res.data)
}
} else {
const data = JSON.parse(res.data)
if (data.code !== 0) {
uni.onSocketClose(() => {
console.log('手动关闭连接, code = 0')
})
}
if (data.final === 1 && data.code === 0) {
// 存储文件
await this.inFile(this.audioBlob, index)
// 第一保存文件成功后就开始播放
if(this.isFrist) {
this.palyAudio()
}
console.log('接受消息结束', this.isOver)
}
}
})
}
// 获取 wss 地址
async getUrl(text, info) {
const data = {
testId: info.test_id,
target: 'text2audio',
text: text
}
console.log(data)
// 此处的 uni.request 封装过, 演示地址('/api/user/666')
const res = await request('/api/user/666', 'POST', data)
this.ttsUrl = res.data.request_url
}
}
// 分割 文本, 每次请求最长 60 哥字符
function splitTextIntoSubtexts(text, maxLen = 60) {
const punctuation = ['。', '!', '?', ';'];
function findBreakpoint(str, length) {
for (let i = length; i > 0; i--) {
if (punctuation.includes(str[i])) {
return i + 1;
}
}
return length;
}
let result = [];
while (text.length > 0) {
let breakpoint = text.length <= maxLen ? text.length : findBreakpoint(text, maxLen);
result.push(text.substring(0, breakpoint));
text = text.substring(breakpoint);
}
return result;
}
export default TTSPlayer