前言
demo模拟线上客户通话,实现tts播报和asr语音转文本,最终将tts和asr播放的文本进行存储或下载。
相关知识:
(1)网页媒体权限授权
(2)ArrayBuffer、TypeBuffer 截断、拼接
(3)音频流式文件播放方式
(4)ASR、TTS
工程相关:vue2
一、网页授权媒体
这个之前有写过
// 方法就一个
openMedia() {
const that = this;
const gotMediaStream = () => {
that.getMedia = true;
//这里是开始websocket的方法
that.useWebSocket();
};
const handleError = (err) => {
// console.log("navigator catch error", err);
that.$message.error("未获录音权限");
};
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// console.log("getusermedia is not supported!");
that.$message.error("无录音权限");
} else {
const constrants = {
audio: {
volume: { min: 0.0, max: 1.0 },
noiseSuppression: false,
echoCancellation: false,
},
};
navigator.mediaDevices
.getUserMedia(constrants)
.then(gotMediaStream)
.catch(handleError);
}
},
// 初始化的websocket
useWebSocket() {
const that = this;
asrWs = new WebSocket(process.env.VUE_APP_WS_URL);
asrWs.binaryType = "arraybuffer"; // 传输的是 ArrayBuffer 类型的数据
asrWs.onopen = function () {
if (asrWs.readyState === 1) {
// 开启体验和心跳 但是未开始录音
// that.experienceStatus = true;
that.heartStart();
}
};
asrWs.onmessage = function (msg) {
//这里就是相关的业务逻辑了
that.heartStart();
};
asrWs.onclose = function (err) {
console.log(err);
};
asrWs.onerror = function (err) {
that.heartStart();
};
},
修改了一些配置,这个配置解决录音音量较小的问题。
navigator.mediaDevices
.getUserMedia({audio:true })
.then(gotMediaStream)
.catch(handleError);
二、ArrayBuffer、TypeBuffer 截断、拼接
首先,这个 ArrayBuffer 类型化数组,类型化数组是JavaScript操作二进制数据的一个接口。最初为了满足JavaScript与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式的背景下诞生的。
ArrayBuffer
var bf = new ArrayBuffer(40); // 生成了字节长度为40的内存区域
//通过提供的 byteLength 属性返回分配字节的长度
console.log(bf.byteLength); // 40
/*
值得注意的是如果要分配的内存区域很大,有可能分配失败(因为没有那么多的连续空余内存),所以有必要检查是否分配成功。
*/
ArrayBuffer对象有一个slice方法,允许将内存区域的一部分,拷贝生成一个新的ArrayBuffer对象。
const bf = new ArrayBuffer(40);
const newBf = bf.slice(0, 10); // 从0 - 9 不包括 10
上面代码拷贝buffer对象的前10个字节,生成一个新的ArrayBuffer对象。slice方法其实包含两步,第一步是先分配一段新内存,第二步是将原来那个ArrayBuffer对象拷贝过去。
slice方法接受两个参数,第一个参数表示拷贝开始的字节序号,第二个参数表示拷贝截止的字节序号。如果省略第二个参数,则默认到原ArrayBuffer对象的结尾。
除了slice方法,ArrayBuffer对象不提供任何直接读写内存的方法,只允许在其上方建立视图,然后通过视图读写。
TypeBuffer
Int8Array:8位有符号整数,长度1个字节。
Uint8Array:8位无符号整数,长度1个字节。
Int16Array:16位有符号整数,长度2个字节。
Uint16Array:16位无符号整数,长度2个字节。
Int32Array:32位有符号整数,长度4个字节。
Uint32Array:32位无符号整数,长度4个字节。
Float32Array:32位浮点数,长度4个字节。
Float64Array:64位浮点数,长度8个字节。
下面这个文本有相关方法
JavaScript 之 ArrayBuffer_短暂又灿烂的的博客-CSDN博客
我的需求是将多个ArrayBuffer完成拼接
二、ASR/TTS是什么?
ASR:语音转文本,将语音转成对应的文本,然后做其他处理。
有http方式和websocket方式。
http方式
传送还是file,file属于blob 使用FormData
export function updataBlob(data) {
let formData = new FormData();
formData.append("file", data);
formData.append("chatId", store.getters.chatId);
return request({
url: "model/audio/upload",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
}
这种方式需要考虑是否为PCM和还是WAV格式的问题,后端解析需不需要加头
websocket这种方式
这种方式实时传送音频,对音频进行了切片。之前文章中也有代码。
https://mp.csdn.net/mp_blog/creation/editor/124618257
TTS是什么
tts是将文本转成语音。
涉及语音就涉及播放。
(1)如果是一次性返回pcm格式文件,需要播放就需要加上头,wav格式。播放TTS返回的文件
添加依赖
"wav-headers": "^1.0.1"
var getFileHeaders = require("wav-headers");
添加wav请求头
generateWav(buffer) {
var options = {
channels: 1,
sampleRate: 16000,
bitDepth: 16,
dataLength: buffer.length,
};
var headersBuffer = getFileHeaders(options);
var temp = new Uint8Array(buffer.byteLength + headersBuffer.byteLength);
temp.set(new Uint8Array(headersBuffer), 0);
temp.set(new Uint8Array(buffer), headersBuffer.byteLength);
return temp;
},
上传文件 new Blob([this.PCMList], { type: "audio/wav" }); 记住有个[ ] 本来this.PCMList 就是个TypeArray。
下面的生成wav文件下载。
upAudioOne() {
store.commit("audio/SET_AUDIO_CUT_SIZE", -1);
this.PCMList = this.generateWav(this.PCMList);
const blob = new Blob([this.PCMList], { type: "audio/wav" });
updataBlob(blob);
// let blobUrl = window.URL.createObjectURL(blob);
// let link = document.createElement('a')
// link.style.display = 'none'
// link.href = blobUrl
// link.download = 'test' + '.wav'
// document.body.appendChild(link)
// link.click()
},
这个是生成wav文件 直接下载或播放
if (Object.prototype.toString.call(data) == "[object Object]") {
this.$message({
message: data.message,
type: "warning",
duration: 3 * 1000,
});
} else {
if (type) {
this.cunrentAudioUrl = URL.createObjectURL(data);
this.$refs.audio.volume = 0.1;
} else {
const reader = new FileReader();
reader.readAsDataURL(data);
reader.onload = (e) => {
const a = document.createElement("a");
a.download = item.id + ".wav";
a.href = e.target.result;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}
}
(2) 如果返回的是流式文件就需要将文件合成拼接在一起 记录TTS返回音频
concatenate(resultConstructor, ...arrays) {
let totalLength = 0;
let startIndx = 0;
if (!arrays[0].length) {
this.PCMListAllIndex = [];
} else {
startIndx = arrays[0].length;
}
for (let arr of arrays) {
totalLength += arr.length;
}
let result = new resultConstructor(totalLength);
let offset = 0;
for (let arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
this.PCMListAllIndex.push({ start: startIndx, end: totalLength });
return result;
},
使用方法合成
var dataAudio = new Uint8Array(msg.data);
if (that.ttsSart) {
that.PCMList = that.concatenate(
Uint8Array,
that.PCMList,
dataAudio
);
}
TTS流式播放
返回是ArrayBuffer ,这个写了看了一个第三方的做了修改
var bufferSource = null;
class PCMPlayer {
constructor(option) {
this.init(option);
}
// 初始化
init(option) {
// 默认参数
const defaultOption = {
inputCodec: "Int16", // 传入的数据是采用多少位编码,默认16位
channels: 1, // 声道数
sampleRate: 8000, // 采样率 单位Hz
flushTime: 3000, // 缓存时间 单位 ms
};
this.option = Object.assign({}, defaultOption, option); // 实例最终配置参数
this.samples = new Float32Array(); // 样本存放区域
this.audioCtx = null;
this.convertValue = this.getConvertValue();
// 获取类型
this.typedArray = this.getTypedArray();
// 初始下音频上线文本
this.initAudioContext();
// 需要播放资源统计
this.sourceCount = 0;
// pcm二进制文件播放次数
this.bufferSourceEndCount = 0;
// 一次文本二进制文件全部返回会 与tts ws中的 speech_end 相关 设置成false
this.bufferFeed = false;
}
// 设置 tts 传入状态, true: 传送中, false :未开始|传送结束
setBufferFeedState(type = false) {
this.bufferFeed = type;
}
// 创建浮点型数据数组
// 初始某人值
sourceLenInit() {
this.samples = new Float32Array();
// console.log("初始化=》被调用");
this.sourceCount = 0;
this.setBufferFeedState(true);
this.bufferSourceEndCount = 0;
}
// 播放暂停 初始化参数值
ttsClose() {
this.sourceCount = 0;
this.setBufferFeedState();
this.bufferSourceEndCount = 0;
if (bufferSource) {
bufferSource.stop(0); //立即停止
}
this.destroy();
}
// 获取不同TypeArray类型的转化
getConvertValue() {
const inputCodecs = {
Int8: 128,
Int16: 32768,
Int32: 2147483648,
Float32: 1,
};
if (!inputCodecs[this.option.inputCodec])
throw new Error(
"wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32"
);
return inputCodecs[this.option.inputCodec];
}
//获取TypeArray
getTypedArray() {
// 根据传入的目标编码位数
// 选定前端的所需要的保存的二进制数据格式
// 完整TypedArray请看文档
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
const typedArrays = {
Int8: Int8Array,
Int16: Int16Array,
Int32: Int32Array,
Float32: Float32Array,
};
if (!typedArrays[this.option.inputCodec])
throw new Error(
"wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32"
);
return typedArrays[this.option.inputCodec];
}
//初始化音频上下文
initAudioContext() {
this.sourceCount = 0;
this.bufferSourceEndCount = 0;
this.setBufferFeedState();
// 初始化音频上下文的东西
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 控制音量的 GainNode
// https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createGain
//创建音量控制节点
this.gainNode = this.audioCtx.createGain();
this.gainNode.gain.value = 0.1;
this.gainNode.connect(this.audioCtx.destination);
this.startTime = this.audioCtx.currentTime;
}
// 检测 是否为TypeArray
static isTypedArray(data) {
// 检测输入的数据是否为 TypedArray 类型或 ArrayBuffer 类型
return (
(data.byteLength &&
data.buffer &&
data.buffer.constructor == ArrayBuffer) ||
data.constructor == ArrayBuffer
);
}
// 是否为 ArrayBuffer 或者 TypedArray
isSupported(data) {
// 数据类型是否支持
// 目前支持 ArrayBuffer 或者 TypedArray
if (!PCMPlayer.isTypedArray(data))
throw new Error("请传入ArrayBuffer或者任意TypedArray");
return true;
}
// pcm二进制文件输入(一句话多个二进制文件输入)
feed(data) {
if (!this.audioCtx) {
return;
}
this.isSupported(data);
// 获取格式化后的buffer
data = this.getFormatedValue(data);
// 开始拷贝buffer数据
// 新建一个Float32Array的空间
const tmp = new Float32Array(this.samples.length + data.length);
// console.log(data, this.samples, this.samples.length)
// 复制当前的实例的buffer值(历史buff)
// 从头(0)开始复制
tmp.set(this.samples, 0);
// 复制传入的新数据
// 从历史buff位置开始
tmp.set(data, this.samples.length);
// 将新的完整buff数据赋值给samples
// interval定时器也会从samples里面播放数据
this.samples = tmp;
// 播放和回调的相关操作
this.flush();
}
// 获取 对应类型数据的方法
getFormatedValue(data) {
if (data.constructor == ArrayBuffer) {
data = new this.typedArray(data);
} else {
data = new this.typedArray(data.buffer);
}
let float32 = new Float32Array(data.length);
for (let i = 0; i < data.length; i++) {
// buffer 缓冲区的数据,需要是IEEE754 里32位的线性PCM,范围从-1到+1
// 所以对数据进行除法
// 除以对应的位数范围,得到-1到+1的数据
// float32[i] = data[i] / 0x8000;
float32[i] = data[i] / this.convertValue;
}
return float32;
}
// 音量控制
volume(volume) {
this.gainNode.gain.value = volume;
}
// 关闭上线文
destroy() {
this.samples = null;
this.audioCtx.close();
this.audioCtx = null;
}
// 每个音频文件
flush() {
const self = this;
if (this.samples.length) {
this.sourceCount += 1;
}
if (!this.samples.length) {
return;
}
if (!this.audioCtx) {
return;
}
bufferSource = this.audioCtx.createBufferSource();
// 两个方法的回调
if (
typeof this.option.onended === "function" &&
typeof this.option.allPlayed === "function"
) {
bufferSource.onended = function (event) {
self.bufferSourceEndCount += 1;
self.option.allPlayed(
self,
self.bufferSourceEndCount == self.sourceCount && !self.bufferFeed
);
self.option.onended(this, event);
};
}
const length = this.samples.length / this.option.channels;
const audioBuffer = this.audioCtx.createBuffer(
// 声道数
this.option.channels,
// 长度
length,
// 采样率
this.option.sampleRate
);
// 多声道处理
for (let channel = 0; channel < this.option.channels; channel++) {
const audioData = audioBuffer.getChannelData(channel);
let offset = channel;
let decrement = 50;
for (let i = 0; i < length; i++) {
audioData[i] = this.samples[offset];
/* fadein */
if (i < 50) {
audioData[i] = (audioData[i] * i) / 50;
}
/* fadeout*/
if (i >= length - 51) {
audioData[i] = (audioData[i] * decrement--) / 50;
}
offset += this.option.channels;
}
}
if (this.startTime < this.audioCtx.currentTime) {
this.startTime = this.audioCtx.currentTime;
}
// console.log(
// "start vs current " +
// this.startTime +
// " vs " +
// this.audioCtx.currentTime +
// " duration: " +
// audioBuffer.duration
// );
bufferSource.buffer = audioBuffer;
bufferSource.connect(this.gainNode);
bufferSource.start(this.startTime);
// 添加开始播放的回调函数
if (this.sourceCount == 1) {
if (typeof this.option.firstPlay === "function") {
self.option.firstPlay(this, Date.now());
}
}
this.startTime += audioBuffer.duration;
this.samples = new Float32Array();
}
// 暂停
async pause() {
await this.audioCtx.suspend();
}
// 继续播放
async continue() {
await this.audioCtx.resume();
}
// 绑定音频上线文的的状态 - 未使用
bindAudioContextEvent() {
const self = this;
if (typeof self.option.onstatechange === "function") {
this.audioCtx.onstatechange = function (event) {
self.option.onstatechange(this, event, self.audioCtx.state);
};
}
}
// 获取音频上下文的状态
getSate() {
const self = this;
return self.audioCtx.state;
}
}
export default PCMPlayer;
使用页面
typeBuffer 加个wav 头用于播放 和上产后台
npm install wav-headers --save
var ws = null; // 实现WebSocket
import PCMPlayer from "./pcm-player.js";
var getFileHeaders = require("wav-headers");
export default {
data() {
return {
timeoutTTs: 6000, // 6s发一次心跳,比server端设置的连接时间稍微小一点,在接近断开的情况下以通信的方式去重置连接时间。
ttsTimeoutObj: null, // ws定时器
ttsSart: false,
ttsPlayStatus: false,
player: null,
PCMList: new Uint8Array(0),
};
},
created() {
// console.log('获取摄像头和麦克风权限')
},
mounted() {
//初始化 PCMPlayer
this.play();
},
beforeDestroy() {
this.closeServe();
this.ttsStop();
},
methods: {
// 设置wav 头
generateWav(buffer) {
var options = {
channels: 1,
sampleRate: 16000,
bitDepth: 16,
dataLength: buffer.length,
};
var headersBuffer = getFileHeaders(options);
var temp = new Uint8Array(buffer.byteLength + headersBuffer.byteLength);
temp.set(new Uint8Array(headersBuffer), 0);
temp.set(new Uint8Array(buffer), headersBuffer.byteLength);
return temp;
},
// 拼接
concatenate(resultConstructor, ...arrays) {
let totalLength = 0;
for (let arr of arrays) {
totalLength += arr.length;
}
let result = new resultConstructor(totalLength);
let offset = 0;
for (let arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
},
// 初始化 PCMPlayer
play() {
const that = this;
if (this.player) return;
this.player = new PCMPlayer({
encoding: "16bitInt",
channels: 1,
sampleRate: 16000,
// 所有cpm 播放结束
allPlayed: function (r, data) {
console.log("allPlayed", data);
},
// 每个pcm 播放结束
onended: function (r) {
},
// 音频上线状态
onstatechange: function (r) {
},
});
if (!ws) this.useTTSSocket();
},
// 播放暂停 用于打断使用
ttsPlayEnd() {
this.player.ttsClose();
this.ttsPlayStatus = false;
},
// 摧毁状态
ttsStop() {
this.player.destroy();
this.ttsPlayStatus = false;
this.player = null;
},
// 关闭tts服务
closeServe() {
if (ws) {
ws.close(); // 离开路由之后断开websocket连接
ws = null;
}
// 2、通过关闭计时器和倒计时关闭心跳监测
this.heartTTSReset();
},
// 心跳初重置
heartTTSReset: function () {
clearInterval(this.ttsTimeoutObj);
},
// 心跳初始化
heartTTSStart() {
const that = this;
this.ttsTimeoutObj && clearInterval(this.ttsTimeoutObj);
this.ttsTimeoutObj = setInterval(function () {
if (ws.readyState === 1) {
// console.log('连接状态,发送消息保持连接' + new Date())
ws.send(JSON.stringify({ signal: "heart_beat" }));
that.heartTTSStart();
} else {
that.useTTSSocket();
}
}, that.timeoutTTs);
},
// 初始化的websocket
useTTSSocket() {
const that = this;
ws = new WebSocket("ws:/xx.xx.xx.xx:8000");
// 传输的是 ArrayBuffer 类型的数据
ws.binaryType = "arraybuffer";
ws.onopen = function () {
if (ws.readyState === 1) {
that.heartTTSStart();
}
};
ws.onmessage = function (msg) {
if (!that.isArrayBuffer(msg.data)) {
const backMsg = JSON.parse(msg.data);
if (backMsg.message === "speech_start") {
that.PCMList = [];
that.player && that.player.sourceLenInit();
that.ttsSart = true;
}
if (backMsg.message === "timestamp") {
if (that.ttsSart) {
that.setTxtProgress(backMsg.syllable_times);
}
// console.log("播放中");
}
if (backMsg.message === "speech_end") {
that.ttsSart = false;
that.player.bufferFeed = true;
that.player && that.player.setBufferFeedState(false);
}
} else {
const dataAudio = new Uint8Array(msg.data);
if (that.ttsSart) {
that.ttsPlayStatus = true;
that.PCMList = that.concatenate(
Uint8Array,
that.PCMList,
dataAudio
);
that.player.feed(dataAudio);
}
}
that.heartTTSStart();
};
ws.onerror = function (err) {
that.heartTTSStart();
};
},
// 判断返回是否为 ArrayBuffer
isArrayBuffer(data) {
const strType = Object.prototype.toString.call(data);
return strType.includes("ArrayBuffer");
},
// 开始发送文本
startTTS(text) {
const that = this;
this.player.initAudioContext();
setTimeout(() => {
ws.send(
JSON.stringify({
text: text,
speaker: "xiaoqian",
pitch: 70,
volume: 80,
})
);
}, 0);
},
},
};
audioBuffer转wav格式的方式
npm install audiobuffer-to-wav --save
截断获取
watch: {
//播放暂停状态
ttsPlayStatus: {
handler(val) {
if (val) {
this.playLongTime = Date.now();
} else {
//获取播放时间
this.playLongTime = Date.now() - this.playLongTime;
//这里很重要 decodeAudioData 需要的arrayBuffer是需要 有头的wav MP3都是可以
//上一个 调用实现方法中 有PCMList 获取方式
this.PCMList = this.generateWav(this.PCMList);
// 调用截断的方法
this.audioSilce(this.PCMList, this.playLongTime / 1000);
}
},
},
},
//音频截断
audioSilce(arrBuffer, time) {
//兼容写法
// window.AudioContext =
// window.AudioContext ||
// window.webkitAudioContext ||
// window.mozAudioContext ||
// window.msAudioContext;
//创建音频上下文
var audioCtx = new window.AudioContext();
// var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
//异步解析 ARRaybuffer
audioCtx.decodeAudioData(arrBuffer.buffer, function (audioBuffer) {
const channels = audioBuffer.numberOfChannels;
// 声道数量和采样率
// var channels = audioBuffer.numberOfChannels;
const rate = audioBuffer.sampleRate;
// 获取截断的音频
const startOffset = 0;
const endOffset = rate * time;
// 对应的帧数
const frameCount = endOffset - startOffset;
// 创建同样采用率、同样声道数量,长度是前3秒的空的AudioBuffer
const newAudioBuffer = new AudioContext().createBuffer(
channels,
endOffset - startOffset,
rate
);
// 创建临时的Array存放复制的buffer数据
const anotherArray = new Float32Array(frameCount);
// 声道的数据的复制和写入
const offset = 0;
for (let channel = 0; channel < channels; channel++) {
audioBuffer.copyFromChannel(anotherArray, channel, startOffset);
newAudioBuffer.copyToChannel(anotherArray, channel, offset);
}
//播放的方法 方便验证
// setTimeout(() => {
// var source = audioCtx.createBufferSource();
// source.buffer = newAudioBuffer;
// source.connect(gainNode);
// source.start(0);
// source.onended = function (event) {
// console.log("播放结束");
// };
// }, 1000);
const wav = toWav(newAudioBuffer);
const blob = new Blob([wav], { type: "audio/wav" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = "测试.wav";
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
},
引入js
import PCMPlayer from "./pcm-player.js";
方法中调用
this.player.sourcePlayLen 和 playLen 是所有 流式的ArrayBuffer播放完成的判断。
原理:
使用音频上下文及createBufferSource() 实时播放二进制流
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
bufferSource = this.audioCtx.createBufferSource(); 见pcm-player.js源码
每个ArrayBuffer都会播放,每次播放播放会调用 onended方法,记录 每次feed方法的调用的次数(就是sourcePlayLen)和个文件播放完成 onended 次数做个判断。相等就播放完成。
firstPlay这个回调是 sourcePlayLen= 1的的回调,判断音频播放中状态的回调方法。这些和业务相关,自己可以补充回调方法。
var dataAudio = new Uint8Array(msg.data);
if (that.ttsSart) {
that.PCMList = that.concatenate(
Uint8Array,
that.PCMList,
dataAudio
);
that.txtProgreeArr.push(that.sorcePushConut);
// that.ttsPlayStatus = true;
that.player && that.player.feed(dataAudio);
that.sorcePushConut += 1;
}
play() {
const that = this;
if (this.player) return;
this.player = new PCMPlayer({
encoding: "16bitInt",
channels: 1,
sampleRate: 16000,
flushTime: 1000,
firstPlay: function (r) {
that.ttsPlayStatus = true;
},
onended: function (r) {
that.playLen += 1;
if (that.playLen === that.player.sourcePlayLen) {
that.playLen = 0;
that.player.sourceLenInit();
// 正常结束
that.ttsIsInterrupt = true;
that.ttsPlayStatus = false;
that.ttsOnend();
if (that.chatSate == 4) {
that.ttsStop();
}
}
},
})
},
主要的几个方法
创建:this.player = new PCMPlayer({})
暂停:this.player.ttsClose();
摧毁:this.player.destroy();
总结
以上是在项目中,是使用到TTS、ASR不同协议对音频的上传、下载、播放、等功能,用得到请点赞谢谢。