記得好早前在慕課網上看到一款可視化音樂播放器,當前是覺得很是神奇,還能這么玩。由於當時剛剛轉行不久,好多東西看得稀里糊塗不明白,於是趁着現在有時間又重新梳理了一遍,然后參照官網的API模擬做了一款網易播放器。沒有什么創新的點,只是想到了就想做一下而已。
效果可以看這里:http://music.poemghost.com/,如果看不了,說明博主的服務器已經不在工作啦。(建議使用電腦瀏覽器打開,同時切換到手機模式來打開,因為在手機上測試時有問題,而且有很大性能損耗,經常會導致瀏覽器奔潰)
代碼在這里:github
效果圖一覽:

看着自己洋洋灑灑寫了快1000多行的js,我現在心里也是一萬屁草泥馬飄過。當然其中還有很多代碼沒有經過提煉,很多變量可以公用,用對象化的方式來說寫這個會更有條理,這個博主以后有時間再梳理一遍。下面來講講主要的實現過程。
一、整體思路
API可以到https://webaudio.github.io/web-audio-api/#dom-audiobuffersourcenode上面去看,只是一個草案,並沒有納入標准,所以有些地方還是有問題,在下面我會說到我遇到了什么問題。但是這個草案上的東西其實可以做出很多其他的效果。比如多音頻源來達到混音效果、音頻振盪器效果等等...
整體的思路圖如下:

大致上來說就是通過window上的AudioContext方法來創建一個音頻對象,然后連接上數據,分析器和音量控制。最后通過BufferSourceNode的start方法來啟動音頻。
二、具體分析
2.1 路由
routes/index.js
router.get('/', function(req, res, next) {
fs.readdir(media, function(err, names) {
var first = names[0];
// 如果第一個文件不是mp3文件,說明是MAC系統
if (first.indexOf('mp3') === -1) {
first = names[1];
names = names.slice(1);
}
var song = first.split(' - ')[1].replace('.mp3', '');
var singer = first.split(' - ')[0];
if (err) {
console.log(err);
} else {
res.render('index', {
title: '網易雲音樂',
music: names,
posts: listPosts,
song: song,
singer: singer,
post: listPosts[0]
});
}
});
});
這里mac平台和windows不同,mac文件夾會有一個.DS_Store文件,因此作了一點小處理。
另外由於用的海外服務器,所以請求mp3資源的時候會有很長時間,因此我把音頻資源放在了七牛雲,而不是從本地獲取,但是數據還是在本地拿,因為並沒有用到數據庫。
2.2 主頁面
頁面運用了手淘的flexible,因此在最開始切換到手機模式的時候,可能需要刷新一下瀏覽器才能顯示正常。樣式采用的是預處理sass,感興趣的可以去看一下代碼
2.3 創建音頻
/**
* 創建音頻
* @param AudioBuffer buffer AudioBuffer對象
* @return void
*/
function createAudio(buffer) {
// 如果音頻是關閉狀態,則重新新建一個全局音頻上下文
if (ac.state === 'closed') {
ac = new (window.AudioContext || window.webkitAudioContext)();
}
audioBuffer = buffer;
ac.onstatechange = onStateChange;
// 創建BufferSrouceNode
bufferSource = ac.createBufferSource();
bufferSource.buffer = buffer;
// 創建音量節點
gainNode = ac.createGain();
gainNode.gain.value = gainValue;
// 創建分析節點
analyser = ac.createAnalyser();
analyser.fftSize = fftSize;
bufferSource.onended = onPlayEnded;
// 嵌套連接
bufferSource.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(ac.destination);
}
結合上面的圖,這里創建音頻的代碼就比較好理解了。
2.4 播放
播放其實是一個非常簡單的API,直接調用BufferSourceNode的start方法即可,start方法有兩個我們會用到的參數,第一個是開始時間,第二個是時間位移,決定了我們從什么時候開始,這將在跳播的時候會用到。
另外有一個注意的點是,不能再同一個BufferSourceNode上調用兩次start方法,否則會報錯。
bufferSource && bufferSource.start(0);
2.5 獲取頻譜圖數據
/**
* 獲取音頻解析數據
* @return void
*/
function getByteFrequencyData() {
var arr = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(arr);
renderCanvas(arr);
renderInter = window.requestAnimationFrame(getByteFrequencyData);
}
通過不斷觸發這個函數,將最新的數據填充到一個8位的無符號數組中,進而開始渲染數據。此時的音頻范圍默認設置為256。
2.6 音量調節
音量調節也有現成的API,這點也沒什么可講的。
// gain 的值默認為1
// 因此這里如果想做繼續音量放大的可以大於1
// 但是太大可能會出現破音效果,大家感興趣可以試試
gainNode.gain.value = [0 ~ 1];
2.7 暫停與恢復播放
我在AudioBufferSourceNode上找了好久,本來以為有start/stop方法,那么就會有類似於puase方法之類的,但是遺憾的是,確實沒有。最開始我也不知道怎么做播放和暫停,但是好在天無絕人之路,意外發現在全局的AudioContext上有兩個方法resume/suspend,這也是實現播放和暫停的兩個方法。
/**
* 恢復播放
* @return null
*/
function resumeAudio() {
playState = PLAY_STATE.RUNNING;
// 放下磁頭
downPin();
// 在當前AudioContext被掛起的狀態下,才能使用resume進行重新激活
ac.resume();
// 重新恢復可視化
resumeRenderCanvas();
// 重啟定時器
startInter && clearInterval(startInter);
startInter = setInterval(function() {
renderTime(start, executeTime(startSecond));
updateProgress(startSecond, totalTime);
startSecond++;
}, 1000);
}
/**
* 暫停播放
* @return null
*/
function suspendAudio() {
playState = PLAY_STATE.SUSPENDED;
// 停止可視化
stopRenderCanvas();
// 收起磁頭
upPin();
startInter && clearInterval(startInter);
// 掛起當前播放
ac.suspend();
}
2.8 跳動播放
跳動播放需要用到開始時間,這里我默認設置為0,接下來就是時間位移了。通過跳動播放進度條的游標,我們不難計算出我們應該播放的時間。
這里有一個問題,我之前也說到過,就是在同一個AudioBufferSourceNode上不能同時start兩次,那么也就是說,我如果這里再直接調用start(0, offsetTime)將會報錯,是的,這里我也卡了好久,最后再一個論壇(是哪個我倒是忘記了)上給了一個建議,不能同時在一個AudioBufferSourceNode上start兩次,那就在不同的AudioBufferSourceNode上start,也就意味着我可以新建一個節點,然后依然用之前ajax請求到的數據來創建一個新的音頻數據。實驗是可以行的。
/**
* 跳動播放
* @param number time 跳躍時間秒數
* @return void
*/
function skipAudio(time) {
// 先釋放之前的AudioBufferSourceNode對象
// 然后再重新連接
// 因為不允許在一個Node上start兩次
analyser && analyser.disconnect(gainNode);
gainNode && gainNode.disconnect(ac.destination);
bufferSource = ac.createBufferSource();
bufferSource.buffer = audioBuffer;
// 創建音頻節點
gainNode = ac.createGain();
gainNode.gain.value = gainValue;
// 創建分析節點
analyser = ac.createAnalyser();
analyser.fftSize = fftSize;
bufferSource.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(ac.destination);
bufferSource.onended = onPlayEnded;
bufferSource.start(0, time);
playState = PLAY_STATE.RUNNING;
changeSuspendBtn();
// 開始分析
getByteFrequencyData();
// 填充當前播放的時間
renderTime(start, executeTime(time));
startSecond = time;
// 放下磁頭
downPin();
// 重新開始計時
startInter && clearInterval(startInter);
startSecond++;
startInter = setInterval(function() {
renderTime(start, executeTime(startSecond));
updateProgress(startSecond, totalTime);
startSecond++;
}, 1000);
}
2.9 列表循環
列表循環用到了bufferSource上的一個回調方法onended,在播放完成之后就自動執行下一曲。
/**
* 播放完成后的回調
* @return null
*/
function onPlayEnded() {
var acState = ac.state;
// 在進行上一曲和下一曲或者跳躍播放的時候
// 如果調用stop方法,會進入當前回調,因此要作區分
// 上一曲和下一曲的時候,由於是新的資源,因此采用關閉當前的AduioContext, load的時候重新生成
// 這樣acState的狀態就是suspended,這樣就不會出現播放錯位
// 而在跳躍播放的時候,由於是同一個資源,因此加上skip標志就可以判斷出來
// 發現如果是循環播放,onPlayEnded方法不會被執行,因此采用加載相同索引的方式
if (acState === 'running' && !skip) {
var index = getNextPlayIndex();
loadMusic(playItems[index], index);
}
}
這里有一個坑就是當我點擊了上一曲和下一曲的時候,發現也會執行這個回調,因此點擊下一曲的時候,實際上播放的是下兩曲的歌曲。因此這里做了區分,當點擊上一曲和下一曲的時候,會給skip設置為true,這樣就不會執行這個方法中默認的行為。
三、手機端會有的問題
之前說過,建議不要在手機端運行,因為會有一些問題,主要表現在:
AudioContext需要兼容,我在Chrome和Safari測試的時候一直得不到音頻數據,之后才發現需要兼容寫法,不然頁面播放不了。兼容寫法為:webkitAudioContext。- 最開始加載音頻的時候,
AudioContext默認的狀態是suspended,這也是我最開始最納悶的事,當我點擊播放按鈕的時候沒有聲音,而點擊跳播的時候會播放聲音,后來調試發現走到了resumeAudio中。 - 性能還是有一定的問題,在手機上播放的時候,經常會出現卡死的現象。渲染柱狀條的時候感到有明顯的卡頓。、
- 由於手機瀏覽器上頁面高度還包括地址欄、導航條高度,因此,唱片可能會超出范圍
四、總結
我就是發現了一個好玩的東西,然后發了興致好好玩了一下,之前照着別人的代碼敲了一遍代碼,后來發現什么都忘了,不如自己動手來得牢靠。有些東西一時看不懂,不要死磕,那是因為水平不夠,不過記住就好,慢慢學習,然后再來攻克它,以此共勉。
