#基于H5的实时语音聊天
业务需求:网页和移动端的通讯,移动端播放g711alaw,难点如下:
- 网页如何调用系统api录音
- 录音后的数据是什么格式?如何转码?
- 如何实时通讯
<input type="text" id="a"/>
<button id="b">buttonB</button>
<button id="c">停止</button>
******************************************
var a = document.getElementById('a');
var b = document.getElementById('b');
var c = document.getElementById('c');
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
var gRecorder = null;
var audio = document.querySelector('audio');
var door = false;
var ws = null;
b.onclick = function() {
if(a.value === '') {
alert('请输入用户名');
return false;
}
if(!navigator.getUserMedia) {
alert('抱歉您的设备无法语音聊天');
return false;
}
SRecorder.get(function (rec) {
gRecorder = rec;
});
ws = new WebSocket("wss://x.x.x.x:8888");
ws.onopen = function() {
console.log('握手成功');
ws.send('user:' + a.value);
};
ws.onmessage = function(e) {
receive(e.data);
};
document.onkeydown = function(e) {
if(e.keyCode === 65) {
if(!door) {
gRecorder.start();
door = true;
}
}
};
document.onkeyup = function(e) {
if(e.keyCode === 65) {
if(door) {
ws.send(gRecorder.getBlob());
gRecorder.clear();
gRecorder.stop();
door = false;
}
}
}
}
c.onclick = function() {
if(ws) {
ws.close();
}
}
var SRecorder = function(stream) {
config = {};
config.sampleBits = config.smapleBits || 8; //输出采样位数
config.sampleRate = config.sampleRate || (44100 / 6); //输出采样频率
var context = new AudioContext();
var audioInput = context.createMediaStreamSource(stream);
var recorder = context.createScriptProcessor(4096, 1, 1); //录音缓冲区大小,输入通道数,输出通道数
var audioData = {
size: 0 //录音文件长度
, buffer: [] //录音缓存
, inputSampleRate: context.sampleRate //输入采样率
, inputSampleBits: 16 //输入采样数位 8, 16
, outputSampleRate: config.sampleRate //输出采样率
, oututSampleBits: config.sampleBits //输出采样数位 8, 16
, clear: function() {
this.buffer = [];
this.size = 0;
}
, input: function (data) {
this.buffer.push(new Float32Array(data));
this.size += data.length;
}
, compress: function () { //合并压缩
//合并
var data = new Float32Array(this.size);
var offset = 0;
for (var i = 0; i < this.buffer.length; i++) {
data.set(this.buffer[i], offset);
offset += this.buffer[i].length;
}
//压缩
var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
var length = data.length / compression;
var result = new Float32Array(length);
var index = 0, j = 0;
while (index < length) {
result[index] = data[j];
j += compression;
index++;
}
return result;
}
, encodeWAV: function () {
var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
var sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits);
var bytes = this.compress();
var dataLength = bytes.length * (sampleBits / 8);
var buffer = new ArrayBuffer(44 + dataLength);
var data = new DataView(buffer);
var channelCount = 1;//单声道
var offset = 0;
var writeString = function (str) {
for (var i = 0; i < str.length; i++) {
data.setUint8(offset + i, str.charCodeAt(i));
}
};
// 资源交换文件标识符
writeString('RIFF'); offset += 4;
// 下个地址开始到文件尾总字节数,即文件大小-8
data.setUint32(offset, 36 + dataLength, true); offset += 4;
// WAV文件标志
writeString('WAVE'); offset += 4;
// 波形格式标志
writeString('fmt '); offset += 4;
// 过滤字节,一般为 0x10 = 16
data.setUint32(offset, 16, true); offset += 4;
// 格式类别 (PCM形式采样数据)
data.setUint16(offset, 1, true); offset += 2;
// 通道数
data.setUint16(offset, channelCount, true); offset += 2;
// 采样率,每秒样本数,表示每个通道的播放速度
data.setUint32(offset, sampleRate, true); offset += 4;
// 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;
// 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;
// 每样本数据位数
data.setUint16(offset, sampleBits, true); offset += 2;
// 数据标识符
writeString('data'); offset += 4;
// 采样数据总数,即数据总大小-44
data.setUint32(offset, dataLength, true); offset += 4;
// 写入采样数据
if (sampleBits === 8) {
for (var i = 0; i < bytes.length; i++, offset++) {
var s = Math.max(-1, Math.min(1, bytes[i]));
var val = s < 0 ? s * 0x8000 : s * 0x7FFF;
val = parseInt(255 / (65535 / (val + 32768)));
data.setInt8(offset, val, true);
}
} else {
for (var i = 0; i < bytes.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, bytes[i]));
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
return new Blob([data], { type: 'audio/wav' });
}
};
this.start = function () {
audioInput.connect(recorder);
recorder.connect(context.destination);
}
this.stop = function () {
recorder.disconnect();
}
this.getBlob = function () {
return audioData.encodeWAV();
}
this.clear = function() {
audioData.clear();
}
recorder.onaudioprocess = function (e) {
audioData.input(e.inputBuffer.getChannelData(0));
}
};
SRecorder.get = function (callback) {
if (callback) {
if (navigator.getUserMedia) {
navigator.getUserMedia(
{ audio: true },
function (stream) {
var rec = new SRecorder(stream);
callback(rec);
})
}
}
}
function receive(e) {
audio.src = window.URL.createObjectURL(e);
}
上面是一段调用H5 Api发送语音的代码片段,按住A键录音,松开A键发送,实现功能类似于微信,下面先对上面代码进行分析和理解,便于实现我们的实时语音需求。
首先创建AudioContext对象,有这个对象才有后面的H5调用底层硬件功能,
var recorder = context.createScriptProcessor(4096, 1, 1);
这句话创建的4096是录音一次录多少个字节,录到4096字节后会走后面的回调:
recorder.onaudioprocess = function (e) {
audioData.input(e.inputBuffer.getChannelData(0));
}
按住A键后调用gRecorder.start();
this.start = function () {
audioInput.connect(recorder);
recorder.connect(context.destination); //把麦克风的输入和音频采集相连起来 context.destination返回代表在环境中的音频的最终目的地。
}
然后走上面的回调不停的采集麦克风的音频数据input进去,input在这里:
input: function (data) {
this.buffer.push(new Float32Array(data)); //buffer存储float32,size为字节大小,buffer大小为float大小
this.size += data.length;
}
这里将字节流数据,存成Float32Array存在buffer中,也就是说成员变量里面的buffer实际上是多个Float32Array组成的
合并和压缩
因为不同的业务可能要将录制的PCM转成需要的音频格式,所以了解录制的音频是什么格式很有必要
compress: function () { //合并压缩
//合并
var data = new Float32Array(this.size);
var offset = 0;
for (var i = 0; i < this.buffer.length; i++) {
data.set(this.buffer[i], offset); //把buffer中的各个Float32数组合并成一个
offset += this.buffer[i].length;
}
//压缩
var compression = parseInt(this.inputSampleRate / this.outputSampleRate); //6
var length = data.length / compression; //600/6 = 10
var result = new Float32Array(length); // 32 位浮点值的类型化数组
var index = 0, j = 0;
while (index < length) {
result[index] = data[j];
j += compression; //j+=6
index++;
}
return result;
}
合并,不多说,就是将buffer中的各个Float32Array合并成一个,压缩,根据输入采样率和输出采样率计算出一个比例,像我的项目中输入采样频率为48000 我计划生成的音频采样频率为8000 那这里的比值为6,计算出比值后,每隔这个比值6采样一次,平均取点,(经测试这样的简单取点不会有杂音)。
这里再啰嗦下音频的基本知识:
采样频率:一秒钟采样多少次
采样位数:一次采样多大 如果16bit那就是一次采样2byte
声道数(通道数):几个通道采样
这样计算下来你一秒钟采样的大小为:声道数 * 采样频率 * 采样位数/8 (单位:byte)
所以上面的4096字节收集一次数据大概用了多少秒就可以计算出来了 :4096/一秒钟采样大小 (单位:s)
编码
前言:
PCM:电脑录制出的原声,未经压缩,声音还原度高,文件较大
wav:符合RIFF标准的音频文件,不具体只某一种音频编码算法,如wav可以有PCM编码的wav,g711编码的wav…等等,只要他符合RIFF标准
wav头:那么如何叫符合RIFF呢?自己百度。。。wav文件有44个字节的头,头里面告诉你音频是什么格式的,音频数据有多大。。。其实就是一对符合RIFF的结构体
这里面编码成wav,为什么要编码成wav呢,因为他符合RIFF标准,在H5的页面能播放,转成其他音频也方便。因为我们录制的声音文件是PCM格式的
我们录制的PCM数据太大了,也不方便传输,那么就根据需要压缩了一下,再编码成wav,根据自己需要,如果不需要转成wav,那么直接将PCM发送出去就好,后台再对数据进行处理。
###发送方式与后台接收
发送方式基于websocket,我这里后台接收用的java,需要注意两点:1.注意接收缓冲区大小设置足够2.注意捕获onclose里面的reson
@OnMessage(maxMessageSize=160000) //最大160000字节
public void OnMessage2(byte[] message, Session session) {
logger.info("byte:" + message.length);
System.out.println("转化为16进制:"+byteArrayToHexStr(message));
}
@OnClose
public void onClose(Session session, CloseReason reason) {
connList.remove(this);
logger.error("onclose被调用"+reason.toString());
}
如何实时播放语音?
有两种播放声音的方法:
1. audio.src = window.URL.createObjectURL(e);
2.audioContext.decodeAudioData(play_queue[index_play], function(buffer) {//解码成pcm流
var audioBufferSouceNode = audioContext.createBufferSource();
audioBufferSouceNode.buffer = buffer;
audioBufferSouceNode.connect(audioContext.destination);
audioBufferSouceNode.start(0);
}, function(e) {
console.log("failed to decode the file");
});
这里很明显第一种对于实时播放并不管用,因为语音包来的太频繁了,不停得变换src是有声音卡顿的,第二种亲测,有效
这里注意啦:decodeAudioData这个Api很强大,从后台回传来的数据,用前台那个encodeWAV编码成wav后,传入decodeAudioData,是可以转换成声卡直接播放的数据的,也就是说大家不用绞尽脑汁思考如何把后台传来的数据变成Float32Array了,没那个必要。
再一个这里用了个循环缓冲队列,将回传来编码后的音频存在循环缓冲队列中,而播放线程丛这个队列里取数据:play_queue[index_play],给大家提供个思路,有人有需要再贴代码吧。。
老东家代码不能上传,只有测试demo
如不清楚转码是否正确,在使用转码方法转码后,写入文件,用coolpro2 这个工具打开,选择码率,比特率等,然后打开,听听语音是否清晰