websocket实现语音通讯

叁歲伎倆 2022-06-16 01:52 1601阅读 0赞

说到websocket想比大家不会陌生,如果陌生的话也没关系,一句话概括

“WebSocket protocol 是HTML5一种新的协议。它实现了浏览器与服务器全双工通信”

WebSocket相比较传统那些服务器推技术简直好了太多,我们可以挥手向comet和长轮询这些技术说拜拜啦,庆幸我们生活在拥有HTML5的时代~

这篇文章我们将分三部分探索websocket

首先是websocket的常见使用,其次是完全自己打造服务器端websocket,最终是重点介绍利用websocket制作的两个demo,传输图片和在线语音聊天室,let’s go

一、websocket常见用法

这里介绍三种我认为常见的websocket实现……( 注意:本文建立在node上下文环境 )

1、socket.io

先给demo

  1. var http = require('http');
  2. var io = require('socket.io');
  3. var server = http.createServer(function(req, res) {
  4. res.writeHeader(200, {
  5. 'content-type': 'text/html;charset="utf-8"'});
  6. res.end();
  7. }).listen(8888);
  8. var socket =.io.listen(server);
  9. socket.sockets.on('connection', function(socket) {
  10. socket.emit('xxx', {options});
  11. socket.on('xxx', function(data) {
  12. // do someting
  13. });
  14. });

相信知道websocket的同学不可能不知道socket.io,因为socket.io太出名了,也很棒,它本身对超时、握手等都做了处理。我猜测这也是实现websocket使用最多的方式。socket.io最最最优秀的一点就是优雅降级,当浏览器不支持websocket时,它会在内部优雅降级为长轮询等,用户和开发者是不需要关心具体实现的,很方便。

不过事情是有两面性的,socket.io因为它的全面也带来了坑的地方,最重要的就是臃肿,它的封装也给数据带来了较多的通讯冗余,而且优雅降级这一优点,也伴随浏览器标准化的进行慢慢失去了光辉
























Chrome

Supported in version 4+

Firefox

Supported in version 4+

Internet Explorer

Supported in version 10+

Opera

Supported in version 10+

Safari

Supported in version 5+

在这里不是指责说socket.io不好,已经被淘汰了,而是有时候我们也可以考虑一些其他的实现~

2、http模块

刚刚说了socket.io臃肿,那现在就来说说便捷的,首先demo

  1. var http = require(‘http’);
  2. var server = http.createServer();
  3. server.on(‘upgrade’, function(req) {
  4. console.log(req.headers);
  5. });
  6. server.listen(8888);

很简单的实现,其实socket.io内部对websocket也是这样实现的,不过后面帮我们封装了一些handle处理,这里我们也可以自己去加上,给出两张socket.io中的源码图

zeyM7b6.png_web

yuuIJrq.png_web

3、ws模块

后面有个例子会用到,这里就提一下,后面具体看~

二、自己实现一套server端websocket

刚刚说了三种常见的websocket实现方式,现在我们想想,对于开发者来说

websocket相对于传统http数据交互模式来说,增加了服务器推送的事件,客户端接收到事件再进行相应处理,开发起来区别并不是太大啊

那是因为那些模块已经帮我们将 数据帧解析 这里的坑都填好了,第二部分我们将尝试自己打造一套简便的服务器端websocket模块

感谢次碳酸钴的研究帮助, 我在这里这部分只是简单说下,如果对此有兴趣好奇的请百度【web技术研究所】

自己完成服务器端websocket主要有两点,一个是使用net模块接受数据流,还有一个是对照官方的帧结构图解析数据,完成这两部分就已经完成了全部的底层工作

首先给一个客户端发送websocket握手报文的抓包内容

客户端代码很简单

  1. ws = new WebSocket("ws://127.0.0.1:8888");

NvE773E.png_web

服务器端要针对这个key验证,就是讲key加上一个特定的字符串后做一次sha1运算,将其结果转换为base64送回去

  1. var crypto = require('crypto');
  2. var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
  3. require('net').createServer(function(o) {
  4. var key;
  5. o.on('data',function(e) {
  6. if(!key) {
  7. // 获取发送过来的KEY
  8. key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
  9. // 连接上WS这个字符串,并做一次sha1运算,最后转换成Base64
  10. key = crypto.createHash('sha1').update(key+WS).digest('base64');
  11. // 输出返回给客户端的数据,这些字段都是必须的
  12. o.write('HTTP/1.1 101 Switching Protocols\r\n');
  13. o.write('Upgrade: websocket\r\n');
  14. o.write('Connection: Upgrade\r\n');
  15. // 这个字段带上服务器处理后的KEY
  16. o.write('Sec-WebSocket-Accept: '+key+'\r\n');
  17. // 输出空行,使HTTP头结束
  18. o.write('\r\n');
  19. }
  20. });
  21. }).listen(8888);

这样握手部分就已经完成了,后面就是数据帧解析与生成的活了

先看下官方提供的帧结构示意图

6JNb6v2.png_web

简单介绍下

FIN为是否结束的标示

RSV为预留空间,0

opcode标识数据类型,是否分片,是否二进制解析,心跳包等等

给出一张opcode对应图

Zr2iuy.png_web

MASK是否使用掩码

Payload len和后面extend payload length表示数据长度,这个是最麻烦的

PayloadLen只有7位,换成无符号整型的话只有0到127的取值,这么小的数值当然无法描述较大的数据,因此规定当数据长度小于或等于125时候它才作为数据长度的描述,如果这个值为126,则时候后面的两个字节来储存数据长度,如果为127则用后面八个字节来储存数据长度

Masking-key掩码

下面贴出解析数据帧的代码

  1. function decodeDataFrame(e) {
  2. var i = 0,
  3. j,s,
  4. frame = {
  5. FIN: e[i] >> 7,
  6. Opcode: e[i++] & 15,
  7. Mask: e[i] >> 7,
  8. PayloadLength: e[i++] & 0x7F
  9. };
  10. if(frame.PayloadLength === 126) {
  11. frame.PayloadLength = (e[i++] << 8) + e[i++];
  12. }
  13. if(frame.PayloadLength === 127) {
  14. i += 4;
  15. frame.PayloadLength = (e[i++] << 24) + (e[i++] << 16) + (e[i++] << 8) + e[i++];
  16. }
  17. if(frame.Mask) {
  18. frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];
  19. for(j = 0, s = []; j < frame.PayloadLength; j++) {
  20. s.push(e[i+j] ^ frame.MaskingKey[j%4]);
  21. }
  22. } else {
  23. s = e.slice(i, i+frame.PayloadLength);
  24. }
  25. s = new Buffer(s);
  26. if(frame.Opcode === 1) {
  27. s = s.toString();
  28. }
  29. frame.PayloadData = s;
  30. return frame;
  31. }

然后是生成数据帧的

  1. function encodeDataFrame(e) {
  2. var s = [],
  3. o = new Buffer(e.PayloadData),
  4. l = o.length;
  5. s.push((e.FIN << 7) + e.Opcode);
  6. if(l < 126) {
  7. s.push(l);
  8. } else if(l < 0x10000) {
  9. s.push(126, (l&0xFF00) >> 8, l&0xFF);
  10. } else {
  11. s.push(127, 0, 0, 0, 0, (l&0xFF000000) >> 24, (l&0xFF0000) >> 16, (l&0xFF00) >> 8, l&0xFF);
  12. }
  13. return Buffer.concat([new Buffer(s), o]);
  14. }

都是按照帧结构示意图上的去处理,在这里不细讲,文章重点在下一部分,如果对这块感兴趣的话可以移步web技术研究所~

三、websocket传输图片和websocket语音聊天室

正片环节到了,这篇文章最重要的还是展示一下websocket的一些使用场景

1、传输图片

我们先想想传输图片的步骤是什么,首先服务器接收到客户端请求,然后读取图片文件,将二进制数据转发给客户端,客户端如何处理?当然是使用FileReader对象了

先给客户端代码

  1. var ws = new WebSocket("ws://xxx.xxx.xxx.xxx:8888");
  2. ws.onopen = function(){
  3. console.log("握手成功");
  4. };
  5. ws.onmessage = function(e) {
  6. var reader = new FileReader();
  7. reader.onload = function(event) {
  8. var contents = event.target.result;
  9. var a = new Image();
  10. a.src = contents;
  11. document.body.appendChild(a);
  12. }
  13. reader.readAsDataURL(e.data);
  14. };

接收到消息,然后readAsDataURL,直接将图片base64添加到页面中

转到服务器端代码

  1. fs.readdir("skyland", function(err, files) {
  2. if(err) {
  3. throw err;
  4. }
  5. for(var i = 0; i < files.length; i++) {
  6. fs.readFile('skyland/' + files[i], function(err, data) {
  7. if(err) {
  8. throw err;
  9. }
  10. o.write(encodeImgFrame(data));
  11. });
  12. }
  13. });
  14. function encodeImgFrame(buf) {
  15. var s = [],
  16. l = buf.length,
  17. ret = [];
  18. s.push((1 << 7) + 2);
  19. if(l < 126) {
  20. s.push(l);
  21. } else if(l < 0x10000) {
  22. s.push(126, (l&0xFF00) >> 8, l&0xFF);
  23. } else {
  24. s.push(127, 0, 0, 0, 0, (l&0xFF000000) >> 24, (l&0xFF0000) >> 16, (l&0xFF00) >> 8, l&0xFF);
  25. }
  26. return Buffer.concat([new Buffer(s), buf]);
  27. }

注意 s.push((1 << 7) + 2) 这一句,这里等于直接把opcode写死了为2,对于Binary Frame,这样客户端接收到数据是不会尝试进行toString的,否则会报错~

代码很简单,在这里向大家分享一下websocket传输图片的速度如何

测试很多张图片,总共8.24M

普通静态资源服务器需要20s左右(服务器较远)

cdn需要2.8s左右

那我们的websocket方式呢??!

答案是同样需要20s左右,是不是很失望……速度就是慢在传输上,并不是服务器读取图片,本机上同样的图片资源,1s左右可以完成……这样看来数据流也无法冲破距离的限制提高传输速度

下面我们来看看websocket的另一个用法~

用websocket搭建语音聊天室

先来整理一下语音聊天室的功能

用户进入频道之后从麦克风输入音频,然后发送给后台转发给频道里面的其他人,其他人接收到消息进行播放

看起来难点在两个地方,第一个是音频的输入,第二是接收到数据流进行播放

先说音频的输入,这里利用了HTML5的getUserMedia方法,不过注意了, 这个方法上线是有大坑的 ,最后说,先贴代码

  1. if (navigator.getUserMedia) {
  2. navigator.getUserMedia(
  3. { audio: true },
  4. function (stream) {
  5. var rec = new SRecorder(stream);
  6. recorder = rec;
  7. })
  8. }

第一个参数是{audio: true},只启用音频,然后创建了一个SRecorder对象,后续的操作基本上都在这个对象上进行。此时如果 代码运行在本地的话 浏览器应该提示你是否启用麦克风输入,确定之后就启动了

接下来我们看下SRecorder构造函数是啥,给出重要的部分

  1. var SRecorder = function(stream) {
  2. ……
  3. var context = new AudioContext();
  4. var audioInput = context.createMediaStreamSource(stream);
  5. var recorder = context.createScriptProcessor(4096, 1, 1);
  6. ……
  7. }

AudioContext是一个音频上下文对象,有做过声音过滤处理的同学应该知道“一段音频到达扬声器进行播放之前,半路对其进行拦截,于是我们就得到了音频数据了,这个拦截工作是由window.AudioContext来做的,我们所有对音频的操作都基于这个对象”,我们可以通过AudioContext创建不同的AudioNode节点,然后添加滤镜播放特别的声音

录音原理一样,我们也需要走AudioContext,不过多了一步对麦克风音频输入的接收上,而不是像往常处理音频一下用ajax请求音频的ArrayBuffer对象再decode,麦克风的接受需要用到createMediaStreamSource方法,注意这个参数就是getUserMedia方法第二个参数的参数

再说createScriptProcessor方法,它官方的解释是:

Creates a ScriptProcessorNode, which can be used for direct audio processing via JavaScript.

——————

概括下就是这个方法是使用JavaScript去处理音频采集操作

终于到音频采集了!胜利就在眼前!

接下来让我们把麦克风的输入和音频采集相连起来

  1. audioInput.connect(recorder);
  2. recorder.connect(context.destination);

context.destination官方解释如下

The destination property of the AudioContext interface returns an AudioDestinationNode representing the final destination of all audio in the context.

——————

context.destination返回代表在环境中的音频的最终目的地。

好,到了此时,我们还需要一个监听音频采集的事件

  1. recorder.onaudioprocess = function (e) {
  2. audioData.input(e.inputBuffer.getChannelData(0));
  3. }

audioData是一个对象,这个是在网上找的,我就加了一个clear方法因为后面会用到,主要有那个encodeWAV方法很赞,别人进行了多次的音频压缩和优化,这个最后会伴随完整的代码一起贴出来

此时整个 用户进入频道之后从麦克风输入音频 环节就已经完成啦,下面就该是向服务器端发送音频流,稍微有点蛋疼的来了,刚才我们说了,websocket通过opcode不同可以表示返回的数据是文本还是二进制数据,而我们onaudioprocess中input进去的是数组,最终播放声音需要的是Blob,{type: ‘audio/wav’}的对象,这样我们就必须要在发送之前将数组转换成WAV的Blob,此时就用到了上面说的encodeWAV方法

服务器似乎很简单,只要转发就行了

本地测试确实可以, 然而天坑来了! 将程序跑在服务器上时候调用getUserMedia方法提示我必须在一个安全的环境,也就是需要https,这意味着ws也必须换成wss…… 所以服务器代码就没有采用我们自己封装的握手、解析和编码了,代码如下

  1. var https = require('https');
  2. var fs = require('fs');
  3. var ws = require('ws');
  4. var userMap = Object.create(null);
  5. var options = {
  6. key: fs.readFileSync('./privatekey.pem'),
  7. cert: fs.readFileSync('./certificate.pem')
  8. };
  9. var server = https.createServer(options, function(req, res) {
  10. res.writeHead({
  11. 'Content-Type' : 'text/html'
  12. });
  13. fs.readFile('./testaudio.html', function(err, data) {
  14. if(err) {
  15. return ;
  16. }
  17. res.end(data);
  18. });
  19. });
  20. var wss = new ws.Server({
  21. server: server});
  22. wss.on('connection', function(o) {
  23. o.on('message', function(message) {
  24. if(message.indexOf('user') === 0) {
  25. var user = message.split(':')[1];
  26. userMap[user] = o;
  27. } else {
  28. for(var u in userMap) {
  29. userMap[u].send(message);
  30. }
  31. }
  32. });
  33. });
  34. server.listen(8888);

代码还是很简单的,使用https模块,然后用了开头说的ws模块,userMap是模拟的频道,只实现转发的核心功能

使用ws模块是因为它配合https实现wss实在是太方便了,和逻辑代码0冲突

https的搭建在这里就不提了,主要是需要私钥、CSR证书签名和证书文件,感兴趣的同学可以了解下(不过不了解的话在现网环境也用不了getUserMedia……)

下面是完整的前端代码

  1. var a = document.getElementById('a');
  2. var b = document.getElementById('b');
  3. var c = document.getElementById('c');
  4. navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
  5. var gRecorder = null;
  6. var audio = document.querySelector('audio');
  7. var door = false;
  8. var ws = null;
  9. b.onclick = function() {
  10. if(a.value === '') {
  11. alert('请输入用户名');
  12. return false;
  13. }
  14. if(!navigator.getUserMedia) {
  15. alert('抱歉您的设备无法语音聊天');
  16. return false;
  17. }
  18. SRecorder.get(function (rec) {
  19. gRecorder = rec;
  20. });
  21. ws = new WebSocket("wss://x.x.x.x:8888");
  22. ws.onopen = function() {
  23. console.log('握手成功');
  24. ws.send('user:' + a.value);
  25. };
  26. ws.onmessage = function(e) {
  27. receive(e.data);
  28. };
  29. document.onkeydown = function(e) {
  30. if(e.keyCode === 65) {
  31. if(!door) {
  32. gRecorder.start();
  33. door = true;
  34. }
  35. }
  36. };
  37. document.onkeyup = function(e) {
  38. if(e.keyCode === 65) {
  39. if(door) {
  40. ws.send(gRecorder.getBlob());
  41. gRecorder.clear();
  42. gRecorder.stop();
  43. door = false;
  44. }
  45. }
  46. }
  47. }
  48. c.onclick = function() {
  49. if(ws) {
  50. ws.close();
  51. }
  52. }
  53. var SRecorder = function(stream) {
  54. config = {};
  55. config.sampleBits = config.smapleBits || 8;
  56. config.sampleRate = config.sampleRate || (44100 / 6);
  57. var context = new AudioContext();
  58. var audioInput = context.createMediaStreamSource(stream);
  59. var recorder = context.createScriptProcessor(4096, 1, 1);
  60. var audioData = {
  61. size: 0 //录音文件长度
  62. , buffer: [] //录音缓存
  63. , inputSampleRate: context.sampleRate //输入采样率
  64. , inputSampleBits: 16 //输入采样数位 8, 16
  65. , outputSampleRate: config.sampleRate //输出采样率
  66. , oututSampleBits: config.sampleBits //输出采样数位 8, 16
  67. , clear: function() {
  68. this.buffer = [];
  69. this.size = 0;
  70. }
  71. , input: function (data) {
  72. this.buffer.push(new Float32Array(data));
  73. this.size += data.length;
  74. }
  75. , compress: function () { //合并压缩
  76. //合并
  77. var data = new Float32Array(this.size);
  78. var offset = 0;
  79. for (var i = 0; i < this.buffer.length; i++) {
  80. data.set(this.buffer[i], offset);
  81. offset += this.buffer[i].length;
  82. }
  83. //压缩
  84. var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
  85. var length = data.length / compression;
  86. var result = new Float32Array(length);
  87. var index = 0, j = 0;
  88. while (index < length) {
  89. result[index] = data[j];
  90. j += compression;
  91. index++;
  92. }
  93. return result;
  94. }
  95. , encodeWAV: function () {
  96. var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
  97. var sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits);
  98. var bytes = this.compress();
  99. var dataLength = bytes.length * (sampleBits / 8);
  100. var buffer = new ArrayBuffer(44 + dataLength);
  101. var data = new DataView(buffer);
  102. var channelCount = 1;//单声道
  103. var offset = 0;
  104. var writeString = function (str) {
  105. for (var i = 0; i < str.length; i++) {
  106. data.setUint8(offset + i, str.charCodeAt(i));
  107. }
  108. };
  109. // 资源交换文件标识符
  110. writeString('RIFF'); offset += 4;
  111. // 下个地址开始到文件尾总字节数,即文件大小-8
  112. data.setUint32(offset, 36 + dataLength, true); offset += 4;
  113. // WAV文件标志
  114. writeString('WAVE'); offset += 4;
  115. // 波形格式标志
  116. writeString('fmt '); offset += 4;
  117. // 过滤字节,一般为 0x10 = 16
  118. data.setUint32(offset, 16, true); offset += 4;
  119. // 格式类别 (PCM形式采样数据)
  120. data.setUint16(offset, 1, true); offset += 2;
  121. // 通道数
  122. data.setUint16(offset, channelCount, true); offset += 2;
  123. // 采样率,每秒样本数,表示每个通道的播放速度
  124. data.setUint32(offset, sampleRate, true); offset += 4;
  125. // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
  126. data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;
  127. // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
  128. data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;
  129. // 每样本数据位数
  130. data.setUint16(offset, sampleBits, true); offset += 2;
  131. // 数据标识符
  132. writeString('data'); offset += 4;
  133. // 采样数据总数,即数据总大小-44
  134. data.setUint32(offset, dataLength, true); offset += 4;
  135. // 写入采样数据
  136. if (sampleBits === 8) {
  137. for (var i = 0; i < bytes.length; i++, offset++) {
  138. var s = Math.max(-1, Math.min(1, bytes[i]));
  139. var val = s < 0 ? s * 0x8000 : s * 0x7FFF;
  140. val = parseInt(255 / (65535 / (val + 32768)));
  141. data.setInt8(offset, val, true);
  142. }
  143. } else {
  144. for (var i = 0; i < bytes.length; i++, offset += 2) {
  145. var s = Math.max(-1, Math.min(1, bytes[i]));
  146. data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
  147. }
  148. }
  149. return new Blob([data], { type: 'audio/wav' });
  150. }
  151. };
  152. this.start = function () {
  153. audioInput.connect(recorder);
  154. recorder.connect(context.destination);
  155. }
  156. this.stop = function () {
  157. recorder.disconnect();
  158. }
  159. this.getBlob = function () {
  160. return audioData.encodeWAV();
  161. }
  162. this.clear = function() {
  163. audioData.clear();
  164. }
  165. recorder.onaudioprocess = function (e) {
  166. audioData.input(e.inputBuffer.getChannelData(0));
  167. }
  168. };
  169. SRecorder.get = function (callback) {
  170. if (callback) {
  171. if (navigator.getUserMedia) {
  172. navigator.getUserMedia(
  173. { audio: true },
  174. function (stream) {
  175. var rec = new SRecorder(stream);
  176. callback(rec);
  177. })
  178. }
  179. }
  180. }
  181. function receive(e) {
  182. audio.src = window.URL.createObjectURL(e);
  183. }

注意:按住a键说话,放开a键发送

自己有尝试不按键实时对讲,通过setInterval发送,但发现杂音有点重,效果不好,这个需要encodeWAV再一层的封装,多去除环境杂音的功能,自己选择了更加简便的按键说话的模式

这篇文章里首先展望了websocket的未来,然后按照规范我们自己尝试解析和生成数据帧,对websocket有了更深一步的了解

最后通过两个demo看到了websocket的潜力,关于语音聊天室的demo涉及的较广,没有接触过AudioContext对象的同学最好先了解下AudioContext

文章到这里就结束啦~有什么想法和问题欢迎大家提出来一起讨论探索~

发表评论

表情:
评论列表 (有 0 条评论,1601人围观)

还没有评论,来说两句吧...

相关阅读