#基于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
前后端demo: https://download.csdn.net/download/qq422243639/10734859

如不清楚转码是否正确,在使用转码方法转码后,写入文件,用coolpro2 这个工具打开,选择码率,比特率等,然后打开,听听语音是否清晰,工具下载地址:
https://download.csdn.net/download/qq422243639/10734845

附赠IE基于ActiveX插件的实时语音解决方式:
https://download.csdn.net/download/qq422243639/10734877

Logo

致力于链接即构和开发者,提供实时互动和元宇宙领域的前沿洞察、技术分享和丰富的开发者活动,共建实时互动世界。

更多推荐