如今的HTML5技术正让网页变得越来越强大,通过其Canvas
标签与AudioContext
对象可以轻松实现之前在Flash或Native App中才能实现的频谱指示器的功能。
Demo: Cyandev Works - HTML5 Audio Visualizing
The AudioContext
interface represents an audio-processing graph built from audio modules linked together, each represented by an AudioNode
.
根据MDN的文档,AudioContext
是一个专门用于音频处理的接口,并且工作原理是将AudioContext
创建出来的各种节点(AudioNode
)相互连接,音频数据流经这些节点并作出相应处理。
创建AudioContext对象
由于浏览器兼容性问题,我们需要为不同浏览器配置AudioContext
,在这里我们可以用下面这个表达式来统一对AudioContext
的访问。
var AudioContext = window.AudioContext || window.webkitAudioContext; var audioContext = new AudioContext(); //实例化AudioContext对象
附. 浏览器兼容性
浏览器 | Chrome | Firefox | IE | Opera | Safari |
---|---|---|---|---|---|
支持版本 | 10.0 | 25.0 | 不支持 | 15.0 | 6.0 |
当然,如果浏览器不支持的话,我们也没有办法,用IE的人们我想也不需要这些效果。但最佳实践是使用的时候判断一下上面声明的变量是否为空,然后再做其他操作。
解码音频文件
读取到的音频文件是二进制类型,我们需要让AudioContext
先对其解码,然后再进行后续操作。
audioContext.decodeAudioData(binary, function(buffer) { ... });
方法decodeAudioData
被调用后,浏览器将开始解码音频文件,这需要一定时间,我们应该让用户知道浏览器正在解码,解码成功后会调用传进去的回调函数,decodeAudioData
还有第三个可选参数是在解码失败时调用的,我们这里就先不实现了。
创建音频处理节点
这是最关键的一步,我们需要两个音频节点:
AudioBufferSourceNode
AnalyserNode
前者是用于播放解码出来的buffer的节点,而后者是用于分析音频频谱的节点,两个节点顺次连接就能完成我们的工作。
创建AudioBufferSourceNode
var audioBufferSourceNode;
audioBufferSourceNode = audioContext.createBufferSource();
创建AnalyserNode
var analyser; analyser = audioContext.createAnalyser(); analyser.fftSize = 256;
上面的fftSize
是用于确定FFT大小的属性,那FFT是什么高三的博主还不知道,其实也不需要知道,总之最后获取到的数组长度应该是fftSize
值的一半,还应该保证它是以2为底的幂。
连接节点
audioBufferSourceNode.connect(analyser);
analyser.connect(audioContext.destination);
上面的audioContext.destination
是音频要最终输出的目标,我们可以把它理解为声卡。所以所有节点中的最后一个节点应该再连接到audioContext.destination
才能听到声音。
播放音频
所有工作就绪,在解码完毕时调用的回调函数中我们就可以开始播放了。
audioBufferSourceNode.buffer = buffer; //回调函数传入的参数 audioBufferSourceNode.start(0); //部分浏览器是noteOn()函数,用法相同
参数代表播放起点,我们这里设置为0意味着从头播放。
文件读取
HTML5支持文件选择、读取的特性,我们利用这个特性可以实现不上传,即播放的功能。
HTML标签
在你的页面中找个位置插入:
<input id="fileChooser" type="file" />
Js逻辑
var file; var fileChooser = document.getElementById('fileChooser'); fileChooser.onchange = function() { if (fileChooser.files[0]) { file = fileChooser.files[0]; // Do something with 'file'... } }
使用FileReader异步读取文件
var fileContent; var fileReader = new FileReader(); fileReader.onload = function(e) { fileContent = e.target.result; // Do something with 'fileContent'... } fileReader.readAsArrayBuffer(file);
其实这里的fileContent
就是上面AudioContext
要解码的那个binary,至此两部分的工作就可以连起来了。
WARNING:
Chrome或Firefox浏览器的跨域访问限制会使FileReader
在本地失效,Chrome用户可在调试时添加命令行参数:
chrome.exe --disable-web-security
Canvas绘制频谱
这一部分我不打算详细叙述,就提几个重点。
AnalyserNode数据解析
在绘制之前通过下面的方法获取到AnalyserNode
分析的数据:
var dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(dataArray);
数组中每个元素是从0到fftSize
属性值的数值,这样我们通过一定比例就能控制能量条的高度等状态。
requestAnimationFrame的使用
要使动画动起来,我们需要不断重绘Canvas
标签里的内容,这就需要requestAnimationFrame
这个函数了,它可以帮你以60fps的帧率绘制动画。
使用方法:
var draw = function() { // ... window.requestAnimationFrame(draw); } window.requestAnimationFrame(draw);
这段代码应该不难理解,就是一个类似递归的调用,但不是递归,有点像Android中的postInvalidate
实例代码
贴上我写的一段绘制代码:
var render = function() { ctx = canvas.getContext("2d"); ctx.strokeStyle = "#00d0ff"; ctx.lineWidth = 2; ctx.clearRect(0, 0, canvas.width, canvas.height); //清理画布 var dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(dataArray); var step = Math.round(dataArray.length / 60); //采样步长 for (var i = 0; i < 40; i++) { var energy = (dataArray[step * i] / 256.0) * 50; for (var j = 0; j < energy; j++) { ctx.beginPath(); ctx.moveTo(20 * i + 2, 200 + 4 * j); ctx.lineTo(20 * (i + 1) - 2, 200 + 4 * j); ctx.stroke(); ctx.beginPath(); ctx.moveTo(20 * i + 2, 200 - 4 * j); ctx.lineTo(20 * (i + 1) - 2, 200 - 4 * j); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(20 * i + 2, 200); ctx.lineTo(20 * (i + 1) - 2, 200); ctx.stroke(); } window.requestAnimationFrame(render); }
OK,大致就是这样,之后可以加一些css样式,完善一下业务逻辑,这里就不再阐释了。最后贴上整理好的全部代码:
HTML 部分
<html> <head> <title>HTML5 Audio Visualizing</title> <style type="text/css"> body { background-color: #222222 } input { color: #ffffff } #wrapper { display: table; width: 100%; height: 100%; } #wrapper-inner { display: table-cell; vertical-align: middle; padding-left: 25%; padding-right: 25%; } #tip { color: #fff; opacity: 0; transition: opacity 1s; -moz-transition: opacity 1s; -webkit-transition: opacity 1s; -o-transition: opacity 1s; } #tip.show { opacity: 1 } </style> <script type="text/javascript" src="./index.js"></script> </head> <body> <div id="wrapper"> <div id="wrapper-inner"> <p id="tip">Decoding...</p> <input id="fileChooser" type="file" /> <br> <canvas id="visualizer" width="800" height="400">Your browser does not support Canvas tag.</canvas> </div> </div> </body> </html>
Js部分
var AudioContext = window.AudioContext || window.webkitAudioContext; //Cross browser variant. var canvas, ctx; var audioContext; var file; var fileContent; var audioBufferSourceNode; var analyser; var loadFile = function() { var fileReader = new FileReader(); fileReader.onload = function(e) { fileContent = e.target.result; decodecFile(); } fileReader.readAsArrayBuffer(file); } var decodecFile = function() { audioContext.decodeAudioData(fileContent, function(buffer) { start(buffer); }); } var start = function(buffer) { if(audioBufferSourceNode) { audioBufferSourceNode.stop(); } audioBufferSourceNode = audioContext.createBufferSource(); audioBufferSourceNode.connect(analyser); analyser.connect(audioContext.destination); audioBufferSourceNode.buffer = buffer; audioBufferSourceNode.start(0); showTip(false); window.requestAnimationFrame(render); } var showTip = function(show) { var tip = document.getElementById('tip'); if (show) { tip.className = "show"; } else { tip.className = ""; } } var render = function() { ctx = canvas.getContext("2d"); ctx.strokeStyle = "#00d0ff"; ctx.lineWidth = 2; ctx.clearRect(0, 0, canvas.width, canvas.height); var dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(dataArray); var step = Math.round(dataArray.length / 60); for (var i = 0; i < 40; i++) { var energy = (dataArray[step * i] / 256.0) * 50; for (var j = 0; j < energy; j++) { ctx.beginPath(); ctx.moveTo(20 * i + 2, 200 + 4 * j); ctx.lineTo(20 * (i + 1) - 2, 200 + 4 * j); ctx.stroke(); ctx.beginPath(); ctx.moveTo(20 * i + 2, 200 - 4 * j); ctx.lineTo(20 * (i + 1) - 2, 200 - 4 * j); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(20 * i + 2, 200); ctx.lineTo(20 * (i + 1) - 2, 200); ctx.stroke(); } window.requestAnimationFrame(render); } window.onload = function() { audioContext = new AudioContext(); analyser = audioContext.createAnalyser(); analyser.fftSize = 256; var fileChooser = document.getElementById('fileChooser'); fileChooser.onchange = function() { if (fileChooser.files[0]) { file = fileChooser.files[0]; showTip(true); loadFile(); } } canvas = document.getElementById('visualizer'); }