Demo && Code
语音聊天
二维码:
Android Chrome支持OK,IOS微信扫码然后用Safari打开测试,需要IOS11以上。
思路
目前主要思路为:
1、navigator.mediaDevices.getUserMedia捕获用户麦克风,获取录音数据;
2、处理音频流最后将数据转换为可播放的数据;
3、用audio标签进行播放。
捕获麦克风
navigator.mediaDevices.getUserMedia 的使用参考捕获用户麦克风 && 摄像头
function startRecord() {
recording = true;
// 重置数据缓冲区
leftchannel.length = rightchannel.length = 0;
recordingLength = 0;
// 断开上一个连接和录音捕获
try {
localMediaStream.getTracks()[0].stop();
audioInput.disconnect();
recorder.disconnect();
} catch(e) {}
navigator.mediaDevices.getUserMedia({audio:{
sampleRate: 44000,
channelCount: 2,
volume: 1.0
}}).then(function(e) {
onSuccess(e);
localMediaStream = e;
}, function(error) {
console.log(error);
});
}
处理音频流
接着对音频流进行处理。利用 Web Audio API 处理数据。 Web Audio API 是一个简单的 API,用于获取输入源并将这些输入源连接到可以处理音频数据(调节增益等)的节点,最终目的是连接到扬声器以便用户能够听到声音。
可以连接的其中一个节点是 ScriptProcessorNode。每次音频缓冲区已满,需要您进行处理时,该节点都会发出一个 onaudioprocess 事件。此时,您可以将数据保存到自己的缓冲区内,留供以后使用。
function onSuccess(e){
audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
// 设置采样率,根据查询上下文采样率(因平台而异)
sampleRate = context.sampleRate;
// 创建 gain 节点
volume = context.createGain();
// 从麦克风输入流创建音频节点
audioInput = context.createMediaStreamSource(e);
// 将流连接到 gain 节点
audioInput.connect(volume);
var bufferSize = 2048;
recorder = context.createScriptProcessor(bufferSize, 2, 2);
recorder.onaudioprocess = function(e){
if (!recording) return;
var left = e.inputBuffer.getChannelData (0);
var right = e.inputBuffer.getChannelData (1);
leftchannel.push (new Float32Array (left.slice(0)));
rightchannel.push (new Float32Array (right.slice(0)));
recordingLength += bufferSize;
}
// 连接录音设备
volume.connect (recorder);
recorder.connect (context.destination);
}
onaudioprocess 会不断触发,我们可以打印发现,录音的数据表示声音的强弱,声波被麦克风转换为不同强度的电流信号,这些数字就代表了信号的强弱。它的取值范围是[-1, 1],表示一个相对比例。
保留在缓冲区内的数据是来自麦克风的原始数据,在这些数据的处理上有以下这几种选择:
将其直接上传至服务器
将其存储在本地
将其转换为专用文件格式(例如 WAV),然后保存至服务器或本地
处理数据
录好一段语音,便可以把保存好的音频流数据转换成可播放的二进制数据。
function endRecord() {
try {
localMediaStream.getTracks()[0].stop();
audioInput.disconnect();
recorder.disconnect();
} catch(e) {}
recording = false;
startTime = 0;
// 将左右通道向下平放
var leftBuffer = mergeBuffers ( leftchannel, recordingLength );
var rightBuffer = mergeBuffers ( rightchannel, recordingLength );
// 交叉合并左右声道
var interleaved = interleave ( leftBuffer, rightBuffer );
// 创建buffer
var buffer = new ArrayBuffer(44 + interleaved.length * 2);
// 创建DataView视图
var view = new DataView(buffer);
// 写入资源交换文件标识符
writeUTFBytes(view, 0, 'RIFF');
// 设置下个地址开始到文件尾总字节数
view.setUint32(4, 44 + interleaved.length * 2, true);
// 写入WAV文件标志
writeUTFBytes(view, 8, 'WAVE');
// FMT 格式标志
writeUTFBytes(view, 12, 'fmt ');
// 设置过滤字节,一般为 0x10 = 16
view.setUint32(16, 16, true);
// 设置格式类别 (PCM形式采样数据)
view.setUint16(20, 1, true);
// 设置通道数 (2 channels)
view.setUint16(22, 2, true);
// 设置采样率,每秒样本数,表示每个通道的播放速度
view.setUint32(24, sampleRate, true);
// 波形数据传输率 (每秒平均字节数)
view.setUint32(28, sampleRate * 4, true);
// 设置快数据调整数 采样一次占用字节数
view.setUint16(32, 4, true);
// 设置单个样本数据位数
view.setUint16(34, 16, true);
// 数据标识符 sub-chunk
writeUTFBytes(view, 36, 'data');
// 设置采样数据总数
view.setUint32(40, interleaved.length * 2, true);
// 写入采样数据
var lng = interleaved.length;
var index = 44;
var volume = 1;
for (var i = 0; i < lng; i++){
view.setInt16(index, interleaved[i] * (0x7FFF * volume), true);
index += 2;
}
// 转换BLOB
var blob = new Blob ( [ view ], { type : 'audio/mpeg' } );
// blob 形式播放
// voiceBox.find('audio')[0].src = URL.createObjectURL(blob);
// $room.append(voiceBox);
// vc.initVoice();
var reader = new FileReader();
reader.onload = function(event){
voiceBox.find('audio')[0].src = event.target.result;
$room.append(voiceBox);
};
// 转换base64
reader.readAsDataURL(blob);
}
// 合并多个Float32Array成一个单个Float32Array
function mergeBuffers(channelBuffer, recordingLength){
var result = new Float32Array(recordingLength);
var offset = 0;
var lng = channelBuffer.length;
for (var i = 0; i < lng; i++){
var buffer = channelBuffer[i];
result.set(buffer, offset);
offset += buffer.length;
}
return result;
}
// 交叉合并左右声道数据
// 因为wav格式存储的时候不是先放左声道再放右声道的
// 是一个左声道数据,一个右声道数据交叉放的
function interleave(leftChannel, rightChannel){
var length = leftChannel.length + rightChannel.length;
var result = new Float32Array(length);
var inputIndex = 0;
for (var index = 0; index < length;){
result[index++] = leftChannel[inputIndex];
result[index++] = rightChannel[inputIndex];
inputIndex++;
}
return result;
}
播放
我们已经把录音的数据赋值给了audio的src,接下来就是处理下播放的效果和页面了。
var vc = {
init: function() {
this.bindEvents();
this.voice = {};
this.initVoice();
},
bindEvents: function() {
var _this = this;
// 音频点击
$(document).on('click', '.voice-one', function() {
var $this = $(this),
vid = $this.attr('vid');
if ($this.hasClass('playing')) {
_this.stopPlay(vid);
} else {
_this.startPlay(vid);
}
});
},
// 初始化音频
initVoice: function() {
var _this = this;
if (!$('.voice-init').length) {
return;
}
function renderDuration(v) {
if ($('.voice-one[vid="'+v+'"]').find('.duration').hasClass('ed')) {
return;
}
if ($.isNumeric(_this.voice[v].duration)) {
var d = Math.ceil(_this.voice[v].duration);
$('.voice-one[vid="'+v+'"]').find('.duration').html(d + '"').addClass('ed');
if (d > 3) {
if (d > 35) {
$('.voice-one[vid="'+v+'"]').css('width', '70%');
} else {
$('.voice-one[vid="'+v+'"]').css('width', (2 * d) + '%');
}
}
}
}
$('.voice-init').each(function() {
var $this = $(this),
vid = $this.attr('vid'),
vSelector = 'voice-' + vid;
_this.voice[vid] = document.getElementById(vSelector);
// 监听音频数据加载
_this.voice[vid].addEventListener('loadeddata', function() {
renderDuration(vid);
});
_this.voice[vid].addEventListener('canplay', function() {
renderDuration(vid);
});
_this.voice[vid].addEventListener('timeupdate', function() {
renderDuration(vid);
});
_this.voice[vid].addEventListener('durationchange', function() {
renderDuration(vid);
});
_this.voice[vid].addEventListener('canplaythrough', function() {
renderDuration(vid);
});
_this.voice[vid].addEventListener('loadedmetadata', function() {
renderDuration(vid);
});
// 监听音频播放完毕
_this.voice[vid].addEventListener('ended', function() {
_this.voice[vid].currentTime = 0;
_this.stopPlay(vid);
});
$this.removeClass('voice-init');
});
// 页面隐藏时停止播放
var visProp = _this.getHackHidden();
if (visProp) {
var evtname = visProp.replace(/[H|h]idden/, '') + 'visibilitychange';
document.addEventListener(evtname, function() {
if (_this.isHidden() || _this.getHackVisibilityState() === 'hidden') {
_this.closeAllVoice();
}
}, false);
}
// 即将离开时触发
window.addEventListener('beforeunload', function() {
_this.closeAllVoice();
}, false);
// 离开时触发
window.addEventListener('unload', function() {
_this.closeAllVoice();
}, false);
},
// 关闭所有音频
closeAllVoice: function(vid) {
for(var i in this.voice) {
if (vid != i) {
this.stopPlay(i);
}
}
},
// 开始音频播放效果
startPlay: function(vid) {
this.closeAllVoice(vid);
this.voice[vid].play();
$('.voice-one[vid="'+vid+'"]').addClass('playing');
},
// 暂停音频播放效果
stopPlay: function(vid) {
this.voice[vid].pause();
$('.voice-one[vid="'+vid+'"]').removeClass('playing');
},
getHackHidden: function() {
if ('hidden' in document) {
return 'hidden';
}
var prefixes = ['webkit', 'moz', 'ms', 'o'];
for (var i = 0; i < prefixes.length; i++) {
if ((prefixes[i] + 'Hidden') in document) {
return prefixes[i] + 'Hidden';
}
}
return null;
},
getHackVisibilityState: function() {
if ('visibilityState' in document) {
return 'visibilityState';
}
var prefixes = ['webkit', 'moz', 'ms', 'o'];
for (var i = 0; i < prefixes.length; i++) {
if ((prefixes[i] + 'VisibilityState') in document) {
return prefixes[i] + 'VisibilityState';
}
}
return null;
},
isHidden: function() {
var prop = this.getHackHidden();
if (!prop) {
return false;
}
return document[prop];
}
}
vc.init();
当前测试的机型较少,目前Android的Chrome和IOS11以上的Safari是比较流畅OK的。
未来
对这个功能的研究不会止步于此,未来还要持续关注和优化。
对了,还要解决那个我暂时还没有找到原因的,navigator.mediaDevices.getUserMedia 调用超过5次就不调用了,也不报错的问题。
感谢阅读。