前言
在上一篇文章中已经对websocket的做了一定的介绍,并给出了一个文本聊天室的例子,本文将继续对其进行功能扩展,加上语音和视频的功能(感觉瞬间高大上了有木有 *_*)
相关技术
在做功能之前也是找了不少资料的,发现网上对于视频通话的实现基本都是采用了WebRTC这么个东东
WebRTC,名称源自网页实时通信(Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的技术,是谷歌2010年以6820万美元收购Global IP Solutions公司而获得的一项技术。2011年5月开放了工程的源代码,在行业内得到了广泛的支持和应用,成为下一代视频通话的标准。
看别人的例子用起来貌似挺简单的样子,但因为是谷歌的东西,还要连STUN服务器什么的,就放弃了深入研究,有兴趣的自行百度吧。本文中是根据websocket和html5的一些特性进行开发的。
主要步骤
1. 调用摄像头
2. 画面捕捉
3. 图片传输
4. 图片接收和绘制
注:上面步骤以视频传输为例,音频部分类似,但也有区别,后面再讲
调用摄像头
jAlert = function(msg, title, callback) {
alert(msg);
callback && callback();
};
//媒体请求成功后调用的回调函数
sucCallBack = function(stream) {
//doSomething
};
//媒体请求失败后调用的回调函数
errCallBack = function(error) {
if (error.PERMISSION_DENIED) {
jAlert('您拒绝了浏览器请求媒体的权限', '提示');
} else if (error.NOT_SUPPORTED_ERROR) {
jAlert('对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器', '提示');
} else if (error.MANDATORY_UNSATISFIED_ERROR) {
jAlert('指定的媒体类型未接收到媒体流', '提示');
} else {
jAlert('相关硬件正在被其他程序使用中', '提示');
}
};
//媒体请求的参数,video:true表示调用摄像头,audio:true表示调用麦克风
option = {video:true}
//兼容各个浏览器
navigator.getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);
var userAgent = navigator.userAgent,
msgTitle = '提示',
notSupport = '对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器';
try {
if (navigator.getUserMedia) {
if (userAgent.indexOf('MQQBrowser') > -1) {
errCallBack({
NOT_SUPPORTED_ERROR: 1
});
return false;
}
navigator.getUserMedia(option, sucCallBack, errCallBack);
} else {
/*
if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Oupeng") == -1 && userAgent.indexOf("360 Aphone") == -1) {
//由于没有Safari浏览器不能对已有方法进行测试,有需要可以自行度之
} //判断是否Safari浏览器
*/
errCallBack({
NOT_SUPPORTED_ERROR: 1
});
return false;
}
} catch (err) {
errCallBack({
NOT_SUPPORTED_ERROR: 1
});
return false;
}
兼容性测试:
IE系列:摄像头/麦克风都不支持
Edge:摄像头/麦克风都支持,不过有个小BUG,请求摄像头的时候会提示“是否允许xxx使用你的麦克风”,影响不大。
Chrome系列(包括支持极速模式的国产山寨浏览器):摄像头基本都支持,语音功能不一定都支持(和chromium的版本以及电脑的声卡驱动有关系),今天更新了qq浏览器,貌似最新的chromium(47.0.2526.80)不能直接访问媒体API了
getUserMedia() no longer works on insecure origins. To use this feature, you should consider switching your application to a secure origin, such as HTTPS. See https://goo.gl/rStTGz for more details.
解决办法:
用HTTPS或者在chrome运行参数里加--able-web-security
Firefox(45.0.2):摄像头/麦克风都支持
- 硬件设备同时仅能被一个浏览器访问,不过同一个浏览器可以打开多个标签页来多次调用
- 文章最后会给出的项目里面有几个页面是用来测试媒体 API 的,自己去寻找吧
画面捕捉
从上面一步我们已经取得了媒体流(sucCallBack中的stream),这一步就要对它进行处理。
首先,将其作为video控件的视频源
sucCallBack = function(stream) {
var video = document.getElementById("myVideo");
video.src = window.URL.createObjectURL(stream);
video.play();
}
接着,将video中的画面绘制到canvas上
var video = document.getElementById("myVideo");
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
video.addEventListener("play", drawCanvas);
function drawCanvas() {
if (video.paused || video.ended) {
return;
}
context.drawImage(video, 0, 0, 640, 360);//将video当前画面绘制到画布上
setTimeout(drawCanvas, 30);
}
图片传输
从canvas获得图片
var canvas = document.getElementById("canvas");
var img = new Image();
//形如"data:image/png;base64,iVBORw0KG..."逗号前内容为文件类型,格式,编码类型,逗号之后为base64编码内容
var url = canvas.toDataURL("image/png");
img.src = url;
document.body.appendChild(img);
通过websocket对图片进行传输,java代码和上文中基本差不多,就多了一个@OnMessage注释的方法,
@OnMessage(maxMessageSize = 10000000)
public void OnMessage(ByteBuffer message) {
//TODO
}
注意maxMessageSize参数,由于一张图片比较大,如果该值设置过小的话,服务端无法接收而导致连接直接断开
定时发送数据
function sendFrame(){
//TODO
setTimeout(sendFrame, 300)
}
//或
setTimeout(function() {
requestAnimationFrame(sendFrame)
}, 300)
requestAnimationFrame的区别在于如果此页面不是浏览器当前窗口的当前标签(即此标签页被挂起),那么其中的回调函数(sendFrame)会被挂起,直到此标签页被激活后再执行,而只使用setTimeout在标签页被挂起的时候还会继续执行。所以requestAnimationFrame可以用来实现
挂起页面达到暂停视频通讯
的效果
客户端有两种发送方式,一种是发送Blob对象
function getWebSocket(host) {
var socket;
if ('WebSocket' in window) {
socket = new WebSocket(host)
} else if ('MozWebSocket' in window) {
socket = new MozWebSocket(host)
}
return socket
};
// 将base64编码的二进制数据转为Blob对象
function dataURLtoBlob(dataurl) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], {
type: mime
});
};
function sendFrame(){
// socket = getWebSocket ("ws://" + window.location.host + "/websocket/chat")
socket.send(dataURLtoBlob(canvas.toDataURL("image/png")));//使用已建立的socket对象发送数据
setTimeout(sendFrame, 300);
}
另一种是先用对象包装,再转为字符串发送
msg = {
type: 3,
msg: canvas.toDataURL("image/png").substring("data:image/png;base64,".length),// 截掉内容头
};
// 对数据进行base64解码,减少要发送的数据量
msg.msg = window.atob(msg.msg);
// 序列化成json串
socket.send(JSON.stringify(msg))
说说这里遇到的坑吧
在第一种方式中,先是获取图片的Blob文件,但为了让其他用户知道是谁发送的,需要发送发送者的username(或者id),本来试过先用string发送username,再紧跟着发送一个Blob,虽然并未出错但是感觉数据多了会错乱,而且接收的时候处理起来比较复杂,于是就想着把username并入Blob。不过貌似没有string和Blob直接合并的方法,于是我先把字符串生成一个Blob("text/plain"),然后把两个Blob合并起来
new Blob([textBlob, imageBlob])
由于第一种方式发送的时候会生成一个Blob对象,再加上是通过setTimeout这种定时递归得到方式发送的,内存占用(在chrome任务管理器中查看)蹭蹭蹭暴涨,没多久就占用几百兆内存,虽然在几处地方手动置为null来释放引用有点改善,但效果也不是很明显,个人感觉是频率太高(100ms间隔即1秒10帧左右的图片)导致GC来不及释放。相较之下,第二种方式虽然内存也会涨,不过基本会稳定在50M左右。这个问题以后再细究,如果哪位看官有想法欢迎评论指教。
在项目代码中设置videoClient.sendType = 1;切换到第一种方式
正常两个人(窗口)视频通讯的时候,第一种方式毫无压力,不过再增加用户之后就爆了,每个窗口都在不断的断开重连,从接收到的数据看应该是数据包间的内容错乱了。因为我一个Blob是用户名和图片数据组合在一起的,形如
zhangsaniVBORw0KG.....
,其中'zhangsan'为用户名,后面部分为图片数据,数据错乱了之后就变成了#%#sanVBORw0KG.....
之类的,这样正常解析就会出错导致连接断开。所以,人多了就只能采用第二种方式发送了。
图片接收和绘制
对应上一步的两种方式,接收图片也有两种方式
在onmessage方法中先这么处理
if (typeof(message.data) == "string") {
//TODO
} else if (message.data instanceof Blob) {
//TODO
}
第一种接收方式(Blob)
function renderFrame(_host, blob) {
readBlobAsDataURL(_host, function(host) {
_host = null;
host = DataURLtoString(host);
var canvas = document.getElementById("canvas"),
url = URL.createObjectURL(blob),
img = new Image();
img.onload = function() {
window.URL.revokeObjectURL(url);
var context = canvas.getContext("2d");
context.drawImage(img, 0, 0, canvas.width, canvas.height);
img = null;
blob = null;
};
img.src = url;
})
};
// 通过FileReader来读取Blob中封装的字符串内容
function readBlobAsDataURL(blob, callback) {
var a = new FileReader();
a.onload = function(e) {
callback(e.target.result);
};
a.readAsDataURL(blob);
}
// 进行base64编码解码
function DataURLtoString(dataurl) {
return window.atob(dataurl.substring("data:text/plain;base64,".length))
}
var offset = 14;//我采用14位长度的时间戳字符串作为username
renderFrame(new Blob([msg.data.slice(0, offset)], {
type: "text/plain"
}), new Blob([msg.data.slice(offset)], {
type: "image/png"
}))
第二种方式
renderFrame2 = function(host, data) {
var canvas = document.getElementById("canvas"),
img = new Image();
img.onload = function() {
window.URL.revokeObjectURL(url);
var context = canvas.getContext("2d");
context.drawImage(img, 0, 0, canvas.width, canvas.height);
img = null;
};
// 对数据重新进行base64编码后作为图片的源
img.src = "data:image/png;base64," + window.btoa(data)
};
// 将socket.onmessage方法中接收到的数据转为msg对象
var msg = JSON.parse(message.data);
renderFrame2(msg.host, msg.msg)
至此视频部分的内容都结束了,接下来讲下音频部分的差异
调用麦克风
同摄像头调用的第一步,设置 option = {audio:true}
得到麦克风的流,但是不能向摄像头一样直接给video控件的src赋值就好了,还需要下面一系列的操作。
1. 创建“录音机对象”
audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
config = {
inputSampleRate: context.sampleRate,//输入采样率,取决于平台
inputSampleBits: 16,//输入采样数位 8, 16
outputSampleRate: 44100 / 6,//输出采样率
oututSampleBits: 8,//输出采样数位 8, 16
channelCount: 2,//声道数
cycle: 500,//更新周期,单位ms
volume: _config.volume || 1 //音量
};
var bufferSize = 4096;//缓存大小
//创建“录音机对象”
recorder = context.createScriptProcessor(bufferSize, config.channelCount, config.channelCount); // 第二个和第三个参数指的是输入和输出的声道数
buffer = [];//音频数据缓冲区
bufferLength = 0;//音频数据缓冲区长度
2. 将音频输入和“录音机对象”关联
//通过音频流创建输入音频对象
audioInput = context.createMediaStreamSource(stream);//stream即getUserMedia成功后得到的流
//设置录音机录音处理事件,每次缓存(上一步中)满了执行回调函数,
recorder.onaudioprocess = function(e) {
var inputbuffer = e.inputBuffer,
channelCount = inputbuffer.numberOfChannels,
length = inputbuffer.length;
channel = new Float32Array(channelCount * length);
for (var i = 0; i < length; i++) {
for (var j = 0; j < channelCount; j++) {
channel[i * channelCount + j] = inputbuffer.getChannelData(j)[i];
}
}
buffer.push(channel);//缓存数据存入音频缓冲区
bufferLength += channel.length;
};
// 创建 '音量对象',作为 '音频输入' 和 '录音机对象' 连接的桥梁
volume = context.createGain();
audioInput.connect(volume);
volume.connect(recorder);
// 当然也可以不通过 '音量对象' 直连 '录音机',但不能同时使用两种方式
// audioInput.connect(this.recorder);
recorder.connect(context.destination);//context.destination为音频输出
//连接完就开始执行onaudioprocess方法
//recorder.disconnect()可以停止“录音”
updateSource(callback);
3. 获取“音频流”
麦克风的输入都已经连接至“录音机对象”,“录音机对象”再将数据不断存入缓冲区,所以要得到稳定的“音频流”(不是真正意义上的流)可以以一定时间间隔(1秒)提取以此缓冲区的数据转为一秒时长的音频文件,将之通过audio控件播放即可。注意,这样处理会导致声音有1秒的延迟,可以减少这个时间间隔接近“实时”的效果。
第一部分:数据压缩合并
function compress() { //合并压缩
//合并
var buffer = this.buffer,
bufferLength = this.bufferLength;
this.buffer = []; //将缓冲区清空
this.bufferLength = 0;
var data = new Float32Array(bufferLength);
for (var i = 0, offset = 0; i < buffer.length; i++) {
data.set(buffer[i], offset);
offset += buffer[i].length;
}
//根据采样频率进行压缩
var config = this.config,
compression = parseInt(config.inputSampleRate / config.outputSampleRate),
//计算压缩率
length = parseInt(data.length / compression),
result = new Float32Array(length);
index = 0;
while (index < length) {
result[index] = data[index++ * compression];
}
return result;//合并压缩后的数据
}
第二部分:将上一步的数据编码成WAV格式的文件
function encodeWAV(bytes) {
var config = this.config,
sampleRate = Math.min(config.inputSampleRate, config.outputSampleRate),
sampleBits = Math.min(config.inputSampleBits, config.oututSampleBits),
dataLength = bytes.length * (sampleBits / 8),
buffer = new ArrayBuffer(44 + dataLength),
view = new DataView(buffer),
channelCount = config.channelCount,
offset = 0,
volume = config.volume;
writeUTFBytes = function(str) {
for (var i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
};
// 资源交换文件标识符
writeUTFBytes('RIFF');
offset += 4;
// 下个地址开始到文件尾总字节数,即文件大小-8
view.setUint32(offset, 44 + dataLength, true);
offset += 4;
// WAV文件标志
writeUTFBytes('WAVE');
offset += 4;
// 波形格式标志
writeUTFBytes('fmt ');
offset += 4;
// 过滤字节,一般为 0x10 = 16
view.setUint32(offset, 16, true);
offset += 4;
// 格式类别 (PCM形式采样数据)
view.setUint16(offset, 1, true);
offset += 2;
// 通道数
view.setUint16(offset, channelCount, true);
offset += 2;
// 采样率,每秒样本数,表示每个通道的播放速度
view.setUint32(offset, sampleRate, true);
offset += 4;
// 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
view.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true);
offset += 4;
// 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
view.setUint16(offset, channelCount * (sampleBits / 8), true);
offset += 2;
// 每样本数据位数
view.setUint16(offset, sampleBits, true);
offset += 2;
// 数据标识符
writeUTFBytes('data');
offset += 4;
// 采样数据总数,即数据总大小-44
view.setUint32(offset, dataLength, true);
offset += 4;
// 写入采样数据
if (sampleBits === 8) {
for (var i = 0; i < bytes.length; i++, offset++) {
var val = bytes[i] * (0x7FFF * volume);
val = parseInt(255 / (65535 / (val + 32768)));
view.setInt8(offset, val, true);
}
} else if (sampleBits === 16) {
for (var i = 0; i < bytes.length; i++, offset += 2) {
var val = bytes[i] * (0x7FFF * volume);
view.setInt16(offset, val, true);
}
}
return new Blob([view], {
type: 'audio/wav'
});
}
第三部:将上步得到的音频文件作为audio控件的声音源
function updateSource(callback) {
var blob = encodeWAV(this.compress());
var audio= document.getElementById("audio");
//对上一秒播放的音频源进行释放
var url = audio.src;
url && window.URL.revokeObjectURL(url);
if (blob.size > 44) { //size为44的时候,数据部分为空
audio.src = window.URL.createObjectURL(blob);
}
setTimeout(function() {
updateSource(callback);
}, config.cycle);
}
麦克风的捕捉比起摄像头真的是麻烦很多,不能直接获取音频流,要进行数据压缩(采样频率,声道数,样本位数),还要转为wav编码(其他音频格式的编码还没去查过)。不过得到音频的Blob文件之后就可以和“视频传输"一样通过Blob或string两种数据类型收发。
总结
- 对于上面提到的“内存泄漏”的情况还有待去研究。
- 音频传输的话,本来音频文件就小,再加上压缩处理,传输的数据很小,但是图片传输就不一样了,我的项目中传输的图片用的100*100规格,我测试了下需要500k/s左右的上传和下载速度,在本机或者局域网中,再大点的尺寸或者用户再多点都毫无压力,但是部署到我的阿里云老爷机(1G内存,单核CPU)上,网速(上传速度最快300k/s)和后台处理性能两方面原因导致了延迟有一两分钟(>_<|||),所以后续还要继续研究图片压缩的问题。
- 终于吃力地把语音视频功能实现了,感觉还是得找机会研究下WebRTC,享受下别人造好的轮子。
参考文章:
彩蛋
整个项目的代码已经上传至github
很多朋友对于项目构建比较疑惑,这里列一下导入步骤:
- Eclipse
1. 从别的项目下复制一个.project文件, 修改其中的projectDescription-name(最好删除一些无关的内容)
2. Eclipse->import->General->Existing Projects into Workspace 导入项目
3. 右键项目Properties, Project Facets勾选 Dynamic Web Module 和 Java, 点击确定
4. 右键项目Properties->Deployment Assembly,删除WebContent这一行,添加WebRoot目录为根目录
5. 进入Java Build Path->Source, Default output floder 修改为 WebRoot/WEB-INF/classes
6. Java Build Path->Libary->Add Libary->Server Runtime, 选择Tomcat
7. 右键tomcat的server,Add and Remove, Add这个项目
8. 双击tomcat的server进入tomcat的配置页,点击Modules标签页->Edit, 将Path设为 /, [Ctrl + S]保存配置
9. 启动server,浏览器访问127.0.0.1:8080
10. 部署项目的另一种方式:tomcat的配置页->Add External Web Module,选择WebRoot目录,对于普通项目更推荐这种方式。
- IntelliJ IDEA
1. File->Open直接打开项目目录
2. 根据右下角的自动检测,配置web.xml
3. Project Struture->Sources,选择src目录右键指定为Sources目录
4. Project Struture->Modules->Use module compile output path, 编译路径修改为 当前项目的WebRoot\WEB-INF\classes
5. Project Struture->Dependencies->Add->Libary->Tomcat(添加tomcat库), Add->Libary->JARs or directories, 引入项目lib下的fastjson包
6. Project Struture->Artifacts->Add->Web Application:Exploded->From Modules->OK(不要使用Facets下提示的自动创建Artifact)
7. 添加tomcat,修改tomcat配置(Classes添加tomcat\lib\websocket-api.jar, 当然这个包可以按第5步单独引入,不过要把Scope改为Provided),Deployment->Add->Artifact
8. 启动tomcat,浏览器访问127.0.0.1:8080
9. 部署项目的另一种方式:修改tomcat配置,Deployment->Add->External Source,选择WebRoot目录,对于普通项目更推荐这种方式。
注意:tomcat的lib不能打包到WEB-INF/lib目录下
项目中的几个页面:
- main.html:聊天室入口页面
- cameraTest.html:测试摄像头功能
- microphoneTest*.html:测试麦克风功能
本文中的代码都是从项目中抽取出来的,并不完整,详细用法还是看项目代码吧。另外,摄像头和麦克风调用我分别封装成Camera类和MicroPhone类,以后简单调用即可,调用方式参见测试页面。
小小的吐槽
简书的风格看着很舒服,写文章排版也很省事,就是滚动条样式没设置,这么丑的滚动条太不和谐了。