前端实现录音有两种方式,一种是使用MediaRecorder,另一种是使用WebRTC的getUserMedia结合AudioContext,MediaRecorder出现得比较早,只不过Safari/Edge等浏览器一直没有实现,所以兼容性不是很好,而WebRTC已经得到了所有主流浏览器的支持,如Safari 11起就支持了。所以我们用WebRTC的方式进行录制。

利用AudioContext播放声音的使用,我已经在《Chrome 66禁止声音自动播放之后》做过介绍,本篇我们会继续用到AudioContext的API.

为实现录音功能,我们先从播放本地文件音乐说起,因为有些API是通用的。

1. 播放本地音频文件实现

播放音频可以使用audio标签,也可以使用AudioContext,audio标签需要一个url,它可以是一个远程的http协议的url,也可以是一个本地的blob协议的url,怎么创建一个本地的url呢?

使用以下html做为说明:

<input type="file" onchange="playMusic.call(this)" class="select-file">
<audio class="audio-node" autoplay></audio>

提供一个file input上传控件让用户选择本地的文件和一个audio标签准备来播放。当用户选择文件后会触发onchange事件,在onchange回调里面就可以拿到文件的内容,如下代码所示:

function playMusic () {if (!this.value) {return;}let fileReader = new FileReader();let file = this.files[0];fileReader.onload = function () {let arrayBuffer = this.result;console.log(arrayBuffer);}fileReader.readAsArrayBuffer(this.files[0]);
}

这里使用一个FileReader读取文件,读取为ArrayBuffer即原始的二进制内容
可以用这个ArrayBuffer实例化一个Uint8Array就能读取它里面的内容,Uint8Array数组里面的每个元素都是一个无符号整型8位数字,即0 ~ 255,相当于每1个字节的0101内容就读取为一个整数。

这个arrayBuffer可以转成一个blob,然后用这个blob生成一个url,如下代码所示:

fileReader.onload = function () {let arrayBuffer = this.result;// 转成一个bloblet blob = new Blob([new Int8Array(this.result)]);// 生成一个本地的blob urllet blobUrl = URL.createObjectURL(blob);console.log(blobUrl);// 给audio标签的src属性document.querySelector('.audio-node').src = blobUrl;
}

主要利用URL.createObjectURL这个API生成一个blob的url,这个url打印出来是这样的:

blob:null/c2df9f4d-a19d-4016-9fb6-b4899bac630d
然后丢给audio标签就能播放了,作用相当于一个远程的http的url.

在使用ArrayBuffer生成blob对象的时候可以指定文件类型或者叫mime类型,如下代码所示:

let blob = new Blob([new Int8Array(this.result)], {type: 'audio/mp3' // files[0].type
});

这个mime可以通过file input的files[0].type得到,而files[0]是一个File实例,File有mime类型,而Blob也有,因为File是继承于Blob的,两者是同根的。所以在上面实现代码里面其实不需要读取为ArrayBuffer然后再封装成一个Blob,直接使用File就行了,如下代码所示:

function playMusic () {if (!this.value) {return;}// 直接使用File对象生成blob urllet blobUrl = URL.createObjectURL(this.files[0]);document.querySelector('.audio-node').src = blobUrl;
}
而使用

AudioContext需要拿到文件的内容,然后手动进行音频解码才能播放。

2. AudioContext的模型

使用AudioContext怎么播放声音,我们拿到一个ArrayBuffer之后,使用AudioContext的decodeAudioData进行解码,生成一个AudioBuffer实例,把它做为AudioBufferSourceNode对象的buffer属性,这个Node继承于AudioNode,它还有connect和start两个方法,start是开始播放,而在开始播放之前,需要调一下connect,把这个Node连结到audioContext.destination即扬声器设备。代码如下所示:

function play (arrayBuffer) {// Safari需要使用webkit前缀let AudioContext = window.AudioContext || window.webkitAudioContext,audioContext = new AudioContext();// 创建一个AudioBufferSourceNode对象,使用AudioContext的工厂函数创建let audioNode = audioContext.createBufferSource();// 解码音频,可以使用Promise,但是较老的Safari需要使用回调audioContext.decodeAudioData(arrayBuffer, function (audioBuffer) {console.log(audioBuffer);audioNode.buffer = audioBuffer;audioNode.connect(audioContext.destination); // 从0s开始播放audioNode.start(0);});
}
fileReader.onload = function () {let arrayBuffer = this.result;play(arrayBuffer);
}

把解码后的audioBuffer打印出来
他有几个对开发人员可见的属性,包括音频时长,声道数量和采样率。从打印的结果可以知道播放的音频是2声道,采样率为44.1k Hz,时长为196.8s。关于声音这些属性的意义可见《从Chrome源码看audio/video流媒体实现一》.

从上面的代码可以看到,利用AudioContext处理声音有一个很重要的枢纽元素AudioNode,上面使用的是AudioBufferSourceNode,它的数据来源于一个解码好的完整的buffer。其它继承于AudioNode的还有GainNode:用于设置音量、BiquadFilterNode:用于滤波、ScriptProcessorNode:提供了一个onaudioprocess的回调让你分析处理音频数据、MediaStreamAudioSourceNode:用于连接麦克风设备,等等。这些结点可以用装饰者模式,一层层connect,如上面代码使用到的bufferSourceNode可以先connect到gainNode,再由gainNode connect到扬声器,就能调整音量了。

这些节点都是使用audioContext的工厂函数创建的,如调createGainNode就可以创建一个gainNode.

说了这么多就是为了录音做准备,录音需要用到ScriptProcessorNode.

3. 录音的实现

上面播放音乐的来源是本地音频文件,而录音的来源是麦克风,为了能够获取调起麦克风并获取数据,需要使用WebRTC的getUserMedia,如下代码所示;

<button onclick="record()">开始录音</button>
<script>
function record () {window.navigator.mediaDevices.getUserMedia({audio: true}).then(mediaStream => {console.log(mediaStream);beginRecord(mediaStream);}).catch(err => {// 如果用户电脑没有麦克风设备或者用户拒绝了,或者连接出问题了等// 这里都会抛异常,并且通过err.name可以知道是哪种类型的错误 console.error(err);})  ;
}
</script>

在调用getUserMedia的时候指定需要录制音频,如果同时需要录制视频那么再加一个video: true就可以了,也可以指定录制的格式:

window.navigator.mediaDevices.getUserMedia({audio: {sampleRate: 44100, // 采样率channelCount: 2,   // 声道volume: 1.0        // 音量}
}).then(mediaStream => {console.log(mediaStream);
});

调用的时候,浏览器会弹一个框,询问用户是否允许使用用麦克风:
如果用户点了拒绝,那么会抛异常,在catch里面可以捕获到,而如果一切顺序的话,将会返回一个MediaStream对象:
它是音频流的抽象,把这个流用来初始化一个MediaStreamAudioSourceNode对象,然后把这个节点connect连接到一个JavascriptProcessorNode,在它的onaudioprocess里面获取到音频数据,然后保存起来,就得到录音的数据。

如果想直接把录的音直接播放出来的话,那么只要把它connect到扬声器就行了,如下代码所示:

function beginRecord (mediaStream) {let audioContext = new (window.AudioContext || window.webkitAudioContext);let mediaNode = audioContext.createMediaStreamSource(mediaStream);// 这里connect之后就会自动播放了mediaNode.connect(audioContext.destination);
}

但一边录一边播的话,如果没用耳机的话容易产生回音,这里不要播放了。

为了获取录到的音的数据,我们把它connect到一个javascriptProcessorNode,为此先创建一个实例:

function createJSNode (audioContext) {const BUFFER_SIZE = 4096;const INPUT_CHANNEL_COUNT = 2;const OUTPUT_CHANNEL_COUNT = 2;// createJavaScriptNode已被废弃let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;creator = creator.bind(audioContext);return creator(BUFFER_SIZE,INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}

这里是使用createScriptProcessor创建的对象,需要传三个参数:一个是缓冲区大小,通常设定为4kB,另外两个是输入和输出频道数量,这里设定为双声道。它里面有两个缓冲区,一个是输入inputBuffer,另一个是输出outputBuffer,它们是AudioBuffer实例。可以在onaudioprocess回调里面获取到inputBuffer的数据,处理之后,然后放到outputBuffer
例如我们可以把第1步播放本音频用到的bufferSourceNode连接到jsNode,然后jsNode再连接到扬声器,就能在process回调里面分批处理声音的数据,如降噪。当扬声器把4kB的outputBuffer消费完之后,就会触发process回调。所以process回调是不断触发的。

在录音的例子里,是要把mediaNode连接到这个jsNode,进而拿到录音的数据,把这些数据不断地push到一个数组,直到录音终止了。如下代码所示:

function onAudioProcess (event) {console.log(event.inputBuffer);
}
function beginRecord (mediaStream) {let audioContext = new (window.AudioContext || window.webkitAudioContext);let mediaNode = audioContext.createMediaStreamSource(mediaStream);// 创建一个jsNodelet jsNode = createJSNode(audioContext);// 需要连到扬声器消费掉outputBuffer,process回调才能触发// 并且由于不给outputBuffer设置内容,所以扬声器不会播放出声音jsNode.connect(audioContext.destination);jsNode.onaudioprocess = onAudioProcess;// 把mediaNode连接到jsNodemediaNode.connect(jsNode);
}

我们把inputBuffer打印出来,可以看到每一段大概是0.09s:
也就是说每隔0.09秒就会触发一次。接下来的工作就是在process回调里面把录音的数据持续地保存起来,如下代码所示,分别获取到左声道和右声道的数据:

function onAudioProcess (event) {let audioBuffer = event.inputBuffer;let leftChannelData = audioBuffer.getChannelData(0),rightChannelData = audioBuffer.getChannelData(1);console.log(leftChannelData, rightChannelData);
}

打印出来可以看到它是一个Float32Array,即数组里的每个数字都是32位的单精度浮点数

这里有个问题,录音的数据到底表示的是什么呢,它是采样采来的表示声音的强弱,声波被麦克风转换为不同强度的电流信号,这些数字就代表了信号的强弱。它的取值范围是[-1, 1],表示一个相对比例。

然后不断地push到一个array里面:

let leftDataList = [],rightDataList = [];
function onAudioProcess (event) {let audioBuffer = event.inputBuffer;let leftChannelData = audioBuffer.getChannelData(0),rightChannelData = audioBuffer.getChannelData(1);// 需要克隆一下leftDataList.push(leftChannelData.slice(0));rightDataList.push(rightChannelData.slice(0));
}

最后加一个停止录音的按钮,并响应操作:

function stopRecord () {// 停止录音mediaStream.getAudioTracks()[0].stop();mediaNode.disconnect();jsNode.disconnect();console.log(leftDataList, rightDataList);
}

把保存的数据打印出来是一个普通数组里面有很多个Float32Array,接下来它们合成一个单个Float32Array:

function mergeArray (list) {let length = list.length * list[0].length;let data = new Float32Array(length),offset = 0;for (let i = 0; i < list.length; i++) {data.set(list[i], offset);offset += list[i].length;}return data;
}
function stopRecord () {// 停止录音let leftData = mergeArray(leftDataList),rightData = mergeArray(rightDataList);
}

那为什么一开始不直接就弄成一个单个的,因为这种Array不太方便扩容。一开始不知道数组总的长度,因为不确定要录多长,所以等结束录音的时候再合并一下比较方便。

然后把左右声道的数据合并一下,wav格式存储的时候并不是先放左声道再放右声道的,而是一个左声道数据,一个右声道数据交叉放的,如下代码所示:

// 交叉合并左右声道的数据

function interleaveLeftAndRight (left, right) {let totalLength = left.length + right.length;let data = new Float32Array(totalLength);for (let i = 0; i < left.length; i++) {let k = i * 2;data[k] = left[i];data[k + 1] = right[i];}return data;
}

最后创建一个wav文件,首先写入wav的头部信息,包括设置声道、采样率、位声等,如下代码所示:

function createWavFile (audioData) {const WAV_HEAD_SIZE = 44;let buffer = new ArrayBuffer(audioData.length * 2 + WAV_HEAD_SIZE),// 需要用一个view来操控bufferview = new DataView(buffer);// 写入wav头部信息// RIFF chunk descriptor/identifierwriteUTFBytes(view, 0, 'RIFF');// RIFF chunk lengthview.setUint32(4, 44 + audioData.length * 2, true);// RIFF typewriteUTFBytes(view, 8, 'WAVE');// format chunk identifier// FMT sub-chunkwriteUTFBytes(view, 12, 'fmt ');// format chunk lengthview.setUint32(16, 16, true);// sample format (raw)view.setUint16(20, 1, true);// stereo (2 channels)view.setUint16(22, 2, true);// sample rateview.setUint32(24, 44100, true);// byte rate (sample rate * block align)view.setUint32(28, 44100 * 2, true);// block align (channel count * bytes per sample)view.setUint16(32, 2 * 2, true);// bits per sampleview.setUint16(34, 16, true);// data sub-chunk// data chunk identifierwriteUTFBytes(view, 36, 'data');// data chunk lengthview.setUint32(40, audioData.length * 2, true);
}
function writeUTFBytes (view, offset, string) {var lng = string.length;for (var i = 0; i < lng; i++) { view.setUint8(offset + i, string.charCodeAt(i));}
}

接下来写入录音数据,我们准备写入16位位深即用16位二进制表示声音的强弱,16位表示的范围是 [-32768, +32767],最大值是32767即0x7FFF,录音数据的取值范围是[-1, 1],表示相对比例,用这个比例乘以最大值就是实际要存储的值。如下代码所示:

function createWavFile (audioData) {// 写入wav头部,代码同上// 写入PCM数据let length = audioData.length;let index = 44;let volume = 1;for (let i = 0; i < length; i++) {view.setInt16(index, audioData[i] * (0x7FFF * volume), true);index += 2;}return buffer;
}

最后,再用第1点提到的生成一个本地播放的blob url就能够播放刚刚录的音了,如下代码所示:

function playRecord (arrayBuffer) {let blob = new Blob([new Uint8Array(arrayBuffer)]);let blobUrl = URL.createObjectURL(blob);document.querySelector('.audio-node').src = blobUrl;
}
function stopRecord () {// 停止录音let leftData = mergeArray(leftDataList),rightData = mergeArray(rightDataList);let allData = interleaveLeftAndRight(leftData, rightData);let wavBuffer = createWavFile(allData);playRecord(wavBuffer);
}

或者是把blob使用FormData进行上传。

整一个录音的实现基本就结束了,代码参考了一个录音库RecordRTC。

4. 小结

先调用webRTC的getUserMediaStream获取音频流,用这个流初始化一个mediaNode,把它connect连接到一个jsNode,在jsNode的process回调里面不断地获取到录音的数据,停止录音后,把这些数据合并换算成16位的整型数据,并写入wav头部信息生成一个wav音频文件的内存buffer,把这个buffer封装成Blob文件,生成一个url,就能够在本地播放,或者是借助FormData进行上传。这个过程理解了就不是很复杂了。

本篇涉及到了WebRTC和AudioContext的API,重点介绍了AudioContext整体的模型,并且知道了音频数据实际上就是声音强弱的记录,存储的时候通过乘以16位整数的最大值换算成16位位深的表示。同时可利用blob和URL.createObjectURL生成一个本地数据的blob链接。

RecordRTC录音库最后面还使用了webworker进行合并左右声道数据和生成wav文件,可进一步提高效率,避免录音文件太大后面处理的时候卡住了。

文章参考于知乎一位作者https://zhuanlan.zhihu.com/p/43581133?utm_source=wechat_session,如有侵权,请联系

上面有一个问题,如果按照上面的代码书写,停止语音录制时会获取不到mediaStream,mediaNode,jsNode,我使用的时候使用全局变量进行定义即可,在定义这三个函数的时候,使用window.变量名的形式自定义变量,然后在停止录音的方法中同样是使用window.变量名的方式停止即可。

本人是引入到了react项目中,所以将上述所有的需要用到的变量使用state进行复制操作即可,方法名全部改为this.方法名的形式即可正常使用。

最简单的方法是在react项目中,import之后写入这些方法,即可直接调用方法名,不需要更改代码。
使用上述方法,我将该功能分为五个,开启语音,关闭语音,录制语音,停止录制,播放语音,将方法分开即可。

项目完成后,我发现录音的声音很小,而且有杂音,体验效果不是很好,如果有路过的大神懂得如果使用js降噪,请不吝赐教

最后附源码

function record() {window.navigator.mediaDevices.getUserMedia({audio: {sampleRate: 44100, // 采样率channelCount: 2,   // 声道volume: 2.0        // 音量}}).then(mediaStream => {console.log(mediaStream);window.mediaStream = mediaStream// beginRecord(window.mediaStream);}).catch(err => {// 如果用户电脑没有麦克风设备或者用户拒绝了,或者连接出问题了等// 这里都会抛异常,并且通过err.name可以知道是哪种类型的错误 console.error(err);});
}
function beginRecord(mediaStream) {let audioContext = new (window.AudioContext || window.webkitAudioContext);let mediaNode = audioContext.createMediaStreamSource(mediaStream);console.log(mediaNode)window.mediaNode = mediaNode// 这里connect之后就会自动播放了// mediaNode.connect(audioContext.destination);    //直接把录的音直接播放出来// 创建一个jsNodelet jsNode = createJSNode(audioContext);window.jsNode = jsNode// 需要连到扬声器消费掉outputBuffer,process回调才能触发// 并且由于不给outputBuffer设置内容,所以扬声器不会播放出声音jsNode.connect(audioContext.destination);jsNode.onaudioprocess = onAudioProcess;// 把mediaNode连接到jsNodemediaNode.connect(jsNode);
}
function createJSNode(audioContext) {const BUFFER_SIZE = 4096; //4096const INPUT_CHANNEL_COUNT = 2;const OUTPUT_CHANNEL_COUNT = 2;// createJavaScriptNode已被废弃let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;creator = creator.bind(audioContext);return creator(BUFFER_SIZE,INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}
let leftDataList = [],rightDataList = [];
function onAudioProcess(event) {// console.log(event.inputBuffer);let audioBuffer = event.inputBuffer;let leftChannelData = audioBuffer.getChannelData(0),rightChannelData = audioBuffer.getChannelData(1);// console.log(leftChannelData, rightChannelData);// 需要克隆一下leftDataList.push(leftChannelData.slice(0));rightDataList.push(rightChannelData.slice(0));
}
function bofangRecord() {// 播放录音let leftData = mergeArray(leftDataList),rightData = mergeArray(rightDataList);let allData = interleaveLeftAndRight(leftData, rightData);let wavBuffer = createWavFile(allData);playRecord(wavBuffer);
}
function playRecord(arrayBuffer) {let blob = new Blob([new Uint8Array(arrayBuffer)]);let blobUrl = URL.createObjectURL(blob);document.querySelector('.audio-node').src = blobUrl;
}
function stopRecord() {// 停止录音window.mediaNode.disconnect();window.jsNode.disconnect();console.log("已停止录音")// console.log(leftDataList, rightDataList);
}
function recordClose() {// 停止语音window.mediaStream.getAudioTracks()[0].stop();console.log("已停止语音")
}
function mergeArray(list) {let length = list.length * list[0].length;let data = new Float32Array(length),offset = 0;for (let i = 0; i < list.length; i++) {data.set(list[i], offset);offset += list[i].length;}return data;
}
function interleaveLeftAndRight(left, right) {// 交叉合并左右声道的数据let totalLength = left.length + right.length;let data = new Float32Array(totalLength);for (let i = 0; i < left.length; i++) {let k = i * 2;data[k] = left[i];data[k + 1] = right[i];}return data;
}
function createWavFile(audioData) {const WAV_HEAD_SIZE = 44;let buffer = new ArrayBuffer(audioData.length * 2 + WAV_HEAD_SIZE),// 需要用一个view来操控bufferview = new DataView(buffer);// 写入wav头部信息// RIFF chunk descriptor/identifierwriteUTFBytes(view, 0, 'RIFF');// RIFF chunk lengthview.setUint32(4, 44 + audioData.length * 2, true);// RIFF typewriteUTFBytes(view, 8, 'WAVE');// format chunk identifier// FMT sub-chunkwriteUTFBytes(view, 12, 'fmt ');// format chunk lengthview.setUint32(16, 16, true);// sample format (raw)view.setUint16(20, 1, true);// stereo (2 channels)view.setUint16(22, 2, true);// sample rateview.setUint32(24, 44100, true);// byte rate (sample rate * block align)view.setUint32(28, 44100 * 2, true);// block align (channel count * bytes per sample)view.setUint16(32, 2 * 2, true);// bits per sampleview.setUint16(34, 16, true);// data sub-chunk// data chunk identifierwriteUTFBytes(view, 36, 'data');// data chunk lengthview.setUint32(40, audioData.length * 2, true);// 写入wav头部,代码同上// 写入PCM数据let length = audioData.length;let index = 44;let volume = 1;for (let i = 0; i < length; i++) {view.setInt16(index, audioData[i] * (0x7FFF * volume), true);index += 2;}return buffer;
}
function writeUTFBytes(view, offset, string) {var lng = string.length;for (var i = 0; i < lng; i++) {view.setUint8(offset + i, string.charCodeAt(i));}
}

前端H5实现调用麦克风,录音功能相关推荐

  1. 前端调用麦克风获取实时音频流和录音并上传至后台

    前端调用麦克风获取实时音频流和录音并上传至后台 index.html <!DOCTYPE html> < a href=" ">Default.html&l ...

  2. H5页面调用微信支付

    1.H5页面使用微信支付,首先需要注册微信公众号,在设置与开发>公众号设置>功能设置中配置业务域名.JS接口安全域名.网页授权域名.支付功能页面需在此域名链接下的页面. 2.加入域名后,就 ...

  3. 微信小程序web-view 外部引用h5页面调用摄像头录制视频 配有提示音

    微信小程序web-view 外部引用h5页面调用摄像头录制视频 配有提示音 1.目前的需求是什么 2.都踩了那些坑 1.小程序 2.h5语音提示 3.语音合成声音录制不进去,ios有时候是麦克风,有时 ...

  4. Android 原生webview传递header前端H5如何接收

    开发背景 跟其他公司合作的一个项目,传递参数的方式为原生通过自定义header头参数,由前端来接收. 踩坑 1 原生传参 安卓原生传参的方式很简单,通过webview.loadUrl这个方法,如下: ...

  5. 前端H5项目部署到OSS-利用jenkins实现自动发布【生产环境实战】

    前端H5项目发布到OSS 文章目录 前端H5项目发布到OSS 背景 一.创建Bucket 二.为Bucket绑定自定义域名 1.购买的域名和oss在同一个阿里云账号下(大多数) 2.购买的域名和oss ...

  6. 前端H5怎么切换语言_第一章 产品经理必懂的前端技术- 上

    产品经理为什么要懂一些前端技术? 当前端H5工程师说CSS时,你是否知道他在表达什么? 当andriod工程师说这个文本要用TextView时,你是否明白TextView是什么? 当ios工程师说这个 ...

  7. ios emjoi java_前端App开发,实际工作中三端(android,ios,前端H5)emoji表情显示解决方案...

    想起最近开发APP的时候,产品提的一个需求,用户的帖子正文还有评论内容里,要能够显示emoji表情,因为我们这款app是混合开发的,APP里的发贴,发评论是原生做的(android和ios),但帖子详 ...

  8. JavaScript调用麦克风并录制wav音频

    相信很多人遇到使用JavaScript调用麦克风录音,这里给出实现代码,大家可以在其基础上按特殊需求进行修改. <div><audio controls autoplay>&l ...

  9. H5原生调用摄像头getUserMedia的使用与注意事项

    H5原生调用摄像头getUserMedia的使用与注意事项 附上api文档 1.简单使用 MediaDevices 是一个单例对象.通常,您只需直接使用此对象的成员,例如通过调用navigator.m ...

最新文章

  1. mysql innodb插件_mysql安装innodb插件
  2. PHP新手上路文件上传
  3. vue教程1:第一个页面HelloVue快速搭建
  4. ACM题目————次小生成树
  5. 全国职业院校技能大赛软件测试题目,我校喜获2018全国职业院校技能大赛“软件测试”赛项一等奖...
  6. PyTorch入门-深度学习回顾和PyTorch简介
  7. 交互系统的构建之(三)TTS语音合成的加盟
  8. 蓝桥杯 ALGO-31算法训练 开心的金明(01背包,动态规划)
  9. 某大型银行深化系统技术方案之六:系统架构之运作流程
  10. 基于贝叶斯决策理论的分类方法
  11. 【Word】论文公式居中,编号右对齐
  12. 制作WIN7+XP+DOS+PE多系统启动光盘
  13. 郭盛华动真格了!新公司获百亿融资,网友:还招人不
  14. 存:科幻推荐书单---超经典科幻必读
  15. linux openerp,Linux+OpenERP/ODOO 安装笔记求推荐。
  16. 【Linux】用最形象的例子学习进程,从入门到深入
  17. 单车组装的思路(本文尽量针对2K以内的山地车)
  18. VB无所不能之七:VB的多线程(2)
  19. 图像 经验模态分解 matlab,emd经验模态分解matlab下载地址大全
  20. Create 3.0天空盒无色差还原

热门文章

  1. 10组团队项目-Beta冲刺-5/5
  2. iOS抖音 内部方法 名称
  3. Android studio怎么实现swf播放器
  4. [串口屏定义2022最新版]什么是串口屏?串口屏组成及串口屏方案
  5. Unity发布UWP,Hololens调用外部dll识别二维码,获得中文拼音简码
  6. java mysql分层_java学习(十三)采用MVC分层思想实现转账功能
  7. Hadoop(二)——HDFS的 I/O 流操作
  8. JS 大文件分割上传
  9. 奔跑吧美少女!试试你能跑多远?
  10. 网站分析软件Umami