上篇中讲了,web如何实现录制音频,这一篇中,介绍如何播放录制好的音频,以及如何下载和上传音频。
播放,其实就有很多种方法了,可以先上传到云服务器,然后生成链接,使用audio标签进行播放;当然录制完成之后,没有上传之前,也是可以播放的。
录制中的时候,数据全部存储为this.lBuffer和this.rBuffer,现在就可以使用,不过,当初存储一个怎样的数据呢?先来回顾一下
// 左声道数据
// getChannelData返回Float32Array类型的pcm数据
let lData = e.inputBuffer.getChannelData(0),
rData = null,
vol = 0; // 音量百分比
// console.log(lData)
this.lBuffer.push(new Float32Array(lData));
this.size += lData.length;
// 判断是否有右声道数据
if (this.config.numChannels === 2) {
rData = e.inputBuffer.getChannelData(1);
this.rBuffer.push(new Float32Array(rData));
this.size += rData.length;
}
Float32Array,是使用数组来存一个一个Float32Array数组的,所以,现在获取所有的Float32Array数据,需要先把二维数组,转换为一维数组。
/**
* 将二维数组转一维
*
* @private
* @returns {float32array} 音频pcm二进制数据
* @memberof Recorder
*/
flat() {
let lData = null,
rData = new Float32Array(0); // 右声道默认为0
// 创建存放数据的容器
if (this.config.numChannels === 1) {
lData = new Float32Array(this.size);
} else {
lData = new Float32Array(this.size / 2);
rData = new Float32Array(this.size / 2);
}
// 合并
let offset = 0; // 偏移量计算
// 将二维数据,转成一维数据
// 左声道
this.lBuffer.forEach(buffer => {
lData.set(buffer, offset);
offset += buffer.length;
});
// 右声道
offset = 0;
this.rBuffer.forEach(buffer => {
rData.set(buffer, offset);
offset += buffer.length;
});
return {
left: lData,
right: rData
};
}
// 获取录音数据
getData() {
return this.flat();
}
根据输入和输出的采样率压缩数据,比如输入的采样率是48k的,我们需要的是(输出)的是16k的,由于48k与16k是3倍关系,所以输入数据中每隔3取1位
/**
* 数据合并压缩
* 根据输入和输出的采样率压缩数据,
* 比如输入的采样率是48k的,我们需要的是(输出)的是16k的,由于48k与16k是3倍关系,
* 所以输入数据中每隔3取1位
*
* @param {float32array} data [-1, 1]的pcm数据
* @param {number} inputSampleRate 输入采样率
* @param {number} outputSampleRate 输出采样率
* @returns {float32array} 压缩处理后的二进制数据
*/
export function compress(data, inputSampleRate, outputSampleRate) {
// 压缩,根据采样率进行压缩
let rate = inputSampleRate / outputSampleRate,
compression = Math.max(rate, 1),
lData = data.left,
rData = data.right,
length = Math.floor((lData.length + rData.length) / rate),
result = new Float32Array(length),
index = 0,
j = 0;
// 循环间隔 compression 位取一位数据
while (index < length) {
// 取整是因为存在比例compression不是整数的情况
let temp = Math.floor(j);
result[index] = lData[temp];
index++;
if (rData.length) {
/*
* 双声道处理
* e.inputBuffer.getChannelData(0)得到了左声道4096个样本数据,1是右声道的数据,
* 此处需要组和成LRLRLR这种格式,才能正常播放,所以要处理下
*/
result[index] = rData[temp];
index++;
}
j += compression;
}
// 返回压缩后的一维数据
return result;
}
如果是双声道,那就需要特殊处理,e.inputBuffer.getChannelData(0)得到了左声道4096个样本数据,1是右声道的数据,此处需要组和成LRLRLR这种格式,才能正常播放。
我的电脑上,输入和输出的采样率是一样的,所以都是1
按采样位数重新编码
/**
* 转换到我们需要的对应格式的编码
*
* @param {Float32Array} bytes pcm二进制数据
* @param {number} sampleBits 采样位数
* @param {boolean} littleEdian 是否是小端字节序
* @returns {dataview} pcm二进制数据
*/
export function encodePCM(bytes, sampleBits, littleEdian = true) {
let offset = 0,
dataLength = bytes.length * (sampleBits / 8),
buffer = new ArrayBuffer(dataLength),
data = new DataView(buffer);
// 写入采样数据
if (sampleBits === 8) {
for (let i = 0; i < bytes.length; i++, offset++) {
// 范围[-1, 1]
let s = Math.max(-1, Math.min(1, bytes[i]));
// 8位采样位划分成2^8=256份,它的范围是0-255;
// 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。
let val = s < 0 ? s * 128 : s * 127;
val = +val + 128;
data.setInt8(offset, val);
}
} else {
for (let i = 0; i < bytes.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, bytes[i]));
// 16位的划分的是2^16=65536份,范围是-32768到32767
// 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, littleEdian);
}
}
return data;
}
这里有一个判断是否小端字节序
那什么是字节序,简单来说,就是超过一个字节的数据类型在内存中的存储顺序。目前有两种字节序,大端字节序和小端字节序。详细介绍可以看下面的文章:
https://blog.csdn.net/damanchen/article/details/112424874
阮一峰老师的:
https://www.ruanyifeng.com/blog/2016/11/byte-order.html
在windows平台上是小端字节序(Windos(x86,x64)和Linux(x86,x64)都是Little Endian操作系统,所以默认小端字节序为true。
获取到PCM数据,就是要经历上面的步骤,合并压缩,格式编码
getPCM() {
// 先停止
this.stop();
// 获取pcm数据
let data = this.getData();
// 根据输入输出比例 压缩或扩展
data = compress(data, this.inputSampleRate, this.outputSampleRate);
// 按采样位数重新编码
return encodePCM(data, this.oututSampleBits, this.littleEdian);
}
编码wav,一般wav格式是在pcm文件前增加44个字节的文件头,所以,此处只需要在pcm数据前增加下就行了。
/**
* 编码wav,一般wav格式是在pcm文件前增加44个字节的文件头,
* 所以,此处只需要在pcm数据前增加下就行了。
*
* @param {DataView} bytes pcm二进制数据
* @param {number} inputSampleRate 输入采样率
* @param {number} outputSampleRate 输出采样率
* @param {number} numChannels 声道数
* @param {number} oututSampleBits 输出采样位数
* @param {boolean} littleEdian 是否是小端字节序
* @returns {DataView} wav二进制数据
*/
export function encodeWAV(bytes, inputSampleRate, outputSampleRate, numChannels, oututSampleBits, littleEdian = true) {
let sampleRate = outputSampleRate > inputSampleRate ? inputSampleRate : outputSampleRate, // 输出采样率较大时,仍使用输入的值,
sampleBits = oututSampleBits,
buffer = new ArrayBuffer(44 + bytes.byteLength),
data = new DataView(buffer),
channelCount = numChannels, // 声道
offset = 0;
// 资源交换文件标识符
writeString(data, offset, 'RIFF');
offset += 4;
// 下个地址开始到文件尾总字节数,即文件大小-8
data.setUint32(offset, 36 + bytes.byteLength, littleEdian);
offset += 4;
// WAV文件标志
writeString(data, offset, 'WAVE');
offset += 4;
// 波形格式标志
writeString(data, offset, 'fmt ');
offset += 4;
// 过滤字节,一般为 0x10 = 16
data.setUint32(offset, 16, littleEdian);
offset += 4;
// 格式类别 (PCM形式采样数据)
data.setUint16(offset, 1, littleEdian);
offset += 2;
// 声道数
data.setUint16(offset, channelCount, littleEdian);
offset += 2;
// 采样率,每秒样本数,表示每个通道的播放速度
data.setUint32(offset, sampleRate, littleEdian);
offset += 4;
// 波形数据传输率 (每秒平均字节数) 声道数 × 采样频率 × 采样位数 / 8
data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), littleEdian);
offset += 4;
// 快数据调整数 采样一次占用字节数 声道数 × 采样位数 / 8
data.setUint16(offset, channelCount * (sampleBits / 8), littleEdian);
offset += 2;
// 采样位数
data.setUint16(offset, sampleBits, littleEdian);
offset += 2;
// 数据标识符
writeString(data, offset, 'data');
offset += 4;
// 采样数据总数,即数据总大小-44
data.setUint32(offset, bytes.byteLength, littleEdian);
offset += 4;
// 给wav头增加pcm体
for (let i = 0; i < bytes.byteLength;) {
data.setUint8(offset, bytes.getUint8(i));
offset++;
i++;
}
return data;
}
/**
* 获取WAV编码的二进制数据(dataview)
*
* @returns {dataview} WAV编码的二进制数据
* @memberof Recorder
*/
getWAV() {
let pcmTemp = this.getPCM();
// PCM增加44字节的头就是WAV格式了
return encodeWAV(pcmTemp, this.inputSampleRate,
this.outputSampleRate, this.config.numChannels, this.oututSampleBits, this.littleEdian);;
}
上面拿到WAV数据之后,就可以进行播放了,播放使用window.AudioContext对象。
https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext
let audioData = this.getWAV();
let context = null;
let analyser = null;
/**
* 初始化
*/
function init() {
context = new(window.AudioContext || window.webkitAudioContext)();
analyser = context.createAnalyser();
analyser.fftSize = 2048; // 表示存储频域的大小
}
/**
* play
* @returns {Promise<{}>}
*/
function playAudio() {
isPaused = false;
return context.decodeAudioData(audioData.slice(0), buffer => {
source = context.createBufferSource();
// 播放结束的事件绑定
source.onended = () => {
if (!isPaused) { // 暂停的时候也会触发该事件
// 计算音频总时长
totalTime = context.currentTime - playStamp + playTime;
endplayFn();
}
}
// 设置数据
source.buffer = buffer;
// connect到分析器,还是用录音的,因为播放时不能录音的
source.connect(analyser);
analyser.connect(context.destination);
source.start(0, playTime); // 开始播放
// 记录当前的时间戳,以备暂停时使用
playStamp = context.currentTime;
}, function (e) {
throwError(e);
});
}
AudioContext接口的 decodeAudioData() 方法可用于异步解码音频文件中的 ArrayBuffer。ArrayBuffer 数据可以通过 XMLHttpRequest 和 FileReader 来获取。AudioBuffer 是通过 AudioContext 采样率进行解码的,然后通过回调返回结果。
点击暂停之后,又触发暂停,所以需要获取到最新一次暂停的时间戳
/**
* 暂停播放录音
* @memberof Player
*/
function pausePlay() {
destroySource();
// 多次暂停需要累加
playTime += context.currentTime - playStamp;
isPaused = true;
}
播放的时候,记录了播放的时间戳,就是为了恢复播放的时候使用
/**
* 暂停播放录音
* @memberof Player
*/
function pausePlay() {
destroySource();
// 多次暂停需要累加
playTime += context.currentTime - playStamp;
isPaused = true;
}
/**
* 停止播放
* @memberof Player
*/
function stopPlay() {
playTime = 0;
audioData = null;
destroySource();
}
// 销毁source, 由于 decodeAudioData 产生的source每次停止后就不能使用,所以暂停也意味着销毁,下次需重新启动。
function destroySource() {
if (source) {
source.stop();
source = null;
}
}
其实上面已经拿到WAV数据了,就很好实现下载了。
下载就是创建一个a标签,实现下载功能,拿到Blob数据之后,就可以直接调用下面方法
/**
* 下载录音文件
* @private
* @param {*} blob blob数据
* @param {string} name 下载的文件名
* @param {string} type 下载的文件后缀
*/
function _download(blob, name, type) {
let oA = document.createElement('a');
oA.href = window.URL.createObjectURL(blob);
oA.download = `${ name }.${ type }`;
oA.click();
}
下载格式,可以是wav或者pcm
一般wav格式是在pcm文件前增加44个字节的文件头
/**
* 下载录音的wav数据
*
* @param {blob} 需要下载的blob数据类型
* @param {string} [name='recorder'] 重命名的名字
*/
export function downloadWAV(wavblob, name = 'recorder') {
_download(wavblob, name, 'wav');
}
/**
* 下载录音pcm数据
*
* @param {blob} 需要下载的blob数据类型
* @param {string} [name='recorder'] 重命名的名字
* @memberof Recorder
*/
export function downloadPCM(pcmBlob, name = 'recorder') {
_download(pcmBlob, name, 'pcm');
}
如果需要下载mp3
在不使用第三方库的情况下,将PCM数据转换为MP3是一个复杂的任务,因为MP3是一种有损压缩音频格式,涉及到信号处理和编码技术,比如傅立叶变换、量化、哈夫曼编码等。一种方法是使用lamejs的纯JavaScript MP3编码器,它是LAME MP3编码器的JavaScript移植版本。
// 首先引入lamejs库
import { Mp3Encoder } from 'lamejs';
function convertToMp3 (wavDataView) {
// 获取wav头信息
const wav = lamejs.WavHeader.readHeader(wavDataView); // 此处其实可以不用去读wav头信息,毕竟有对应的config配置
const { channels, sampleRate } = wav;
// 设置一些音频参数
let mp3Encoder = new Mp3Encoder(channels, sampleRate, 128); // 2表示立体声, 44100表示采样率, 128表示比特率
// 获取左右通道数据
const result = recorder.getChannelData()
const buffer = [];
const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2);
const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2);
const remaining = leftData.length + (rightData ? rightData.length : 0);
const maxSamples = 1152;
for (let i = 0; i < remaining; i += maxSamples) {
const left = leftData.subarray(i, i + maxSamples);
let right = null;
let mp3buf = null;
if (channels === 2) {
right = rightData.subarray(i, i + maxSamples);
mp3buf = mp3Encoder.encodeBuffer(left, right);
} else {
mp3buf = mp3Encoder.encodeBuffer(left);
}
if (mp3buf.length > 0) {
buffer.push(mp3buf);
}
}
const enc = mp3Encoder.flush();
if (enc.length > 0) {
buffer.push(enc);
}
return new Blob(buffer, { type: 'audio/mp3' });
}
得到Blob数据,对于上传到云服务器,就是很简单的事情了
具体可以看腾讯云文档:
https://cloud.tencent.com/document/product/436/64960
async uploadRecorder(blobData) {
const fileName = `recorder.wav`
const ossDirPath = ''
const cutImgFile = new File([blobData], fileName, {
type: 'audio/wav',
})
const res = await uploadFileToCos(cutImgFile, ossDirPath)
return res
}
录制的全部流程如下:
好了,这就是录制+播放+下载+上传音频的正确方式,其实上面这些功能,就是第三方库js-audio-recorder的全部源码了
仓库:https://github.com/2fps/recorder
安装:
npm i js-audio-recorder
调用:
import Recorder from 'js-audio-recorder';
let recorder = new Recorder();
<script type="text/javascript" src="./dist/recorder.js">script>
let recorder = new Recorder();
具体的效果就是这样
好了,本次分享到这里就结束了~