HTML5 隨音樂節奏變化的頻譜圖動畫


這里將要介紹的HTML5 音頻處理接口與Audio標簽是不一樣的。頁面上的Audio標簽只是HTML5更語義化的一個表現,而HTML5提供給JavaScript編程用的Audio API則讓我們有能力在代碼中直接操作原始的音頻流數據,對其進行任意加工再造。

   展示HTML5 Audio API 最典型直觀的一個例子就是跟隨音樂節奏變化的頻譜圖,也稱之為可視化效果。本文便是以此為例子展示JavaScript中操作音頻數據的。

文中代碼僅供參考,實際代碼以下載的源碼為准。

了解Audio API
  一段音頻到達揚聲器進行播放之前,半路對其進行攔截,於是我們就得到了音頻數據了,這個攔截工作是由window.AudioContext來做的,我們所有對音頻的操作都基於這個對象。

 
   通過AudioContext可以創建不同各類的 AudioNode,即音頻節點,不同節點作用不同,有的對音頻加上濾鏡比如提高音色(比如BiquadFilterNode),改變單調,有的音頻進行 分割,比如將音源中的聲道分割出來得到左右聲道的聲音(ChannelSplitterNode),有的對音頻數據進行頻譜分析即本文要用到的 (AnalyserNode)。





瀏覽器中的Audio API
統一前綴
   JavaScript中處理音頻首先需要實例化一個音頻上下文類型window.AudioContext。目前Chrome和Firefox對其提供了 支持,但需要相應前綴,Chrome中為window.webkitAudioContext,Firefox中為mozAudioContext。所以 為了讓代碼更通用,能夠同時工作在兩種瀏覽器中,只需要一句代碼將前綴進行統一即可。

window.AudioContext =
    window.AudioContext || window.webkitAudioContext || window.mozAudioContext || window.msAudioContext;

   這是一種常見的用法,或者操作符'||' 連接起來的表達式中,遇到真值即返回。比如在Chrome中,window.AudioContext為undefined,接着往下走,碰到 window.webkitAudioContext不為undefined,表達式在此判斷為真值,所以將其返回,於是此時 window.AudioContext =window.webkitAudioContext ,所以代碼中我們就可以直接使用window.AudioContext 而不用擔心具體Chrome還是Firefox了。

  var audioContext=new window.AudioContext();

考慮瀏覽器不支持的情況
  但這還只是保證了在支持AudioContext的瀏覽器中能正常工作,如果是在IE中,上面實例化對象的操作會失敗,所以有必要加個try catch語句來避免報錯。
  try {
   var audioContext = new window.AudioContext();
 } catch (e) {
    Console.log('!Your browser does not support AudioContext');
 }

這樣就安全多啦,媽媽再不擔心瀏覽器報錯了。

組織代碼
   為了更好地進行編碼,我們創建一個Visualizer對象,把所有相關屬性及方法寫到其中。按照慣例,對象的屬性直接寫在構造器里面,對象的方法寫到原型中。對象內部使用的私有方法以短橫線開頭,不是必要但是種好的命名習慣。其中設置了一些基本的屬性將在后續代碼中使用,詳細的還請參見源碼,這里只簡單展示。

 var Visualizer = function() {
    this.file = null, //要處理的文件,后面會講解如何獲取文件
    this.fileName = null, //要處理的文件的名,文件名
    this.audioContext = null, //進行音頻處理的上下文,稍后會進行初始化
    this.source = null, //保存音頻
 };
 Visualizer.prototype = {
   _prepareAPI: function() {
     //統一前綴,方便調用
     window.AudioContext =
       window.AudioContext || window.webkitAudioContext || window.mozAudioContext || window.msAudioContext;
    //這里順便也將requestAnimationFrame也打個補丁,后面用來寫動畫要用
    window.requestAnimationFrame = 
           window.requestAnimationFrame || window.webkitRequestAnimationFrame || 
        window.mozRequestAnimationFrame || window.msRequestAnimationFrame;
   //安全地實例化一個AudioContext並賦值到Visualizer的audioContext屬性上,方便后面處理音頻使用
   try {
       this.audioContext = new AudioContext();
    } catch (e) {
        console.log('!妳的瀏覽器不支持AudioContext:(');
        console.log(e);
    }
  },
 }
加載音頻文件
不用說,你肯定得先在代碼中獲取到音頻文件,才能夠對其進一步加工。

文件獲取的方法: 讀取文件到JavaScript可以有以下三種方法:
   1.新開一個Ajax異步請求來獲取文件,如果是本地測試需要關掉瀏覽器的同源安全策略才能獲取成功,不然只能把網頁放到服務器上才能正常工作。
   具體說來,就是先開一個XMLHttpRequest請求,將文件路徑作為請求的URL,並且設置請求返回類型為'ArrayBuffer',這種格式方便我們后續的處理。下面是一個例子。
 loadSound("sample.mp3"); //調用
 // 定義加載音頻文件的函數
 function loadSound(url) {
   var request = new XMLHttpRequest(); //建立一個請求
   request.open('GET', url, true); //配置好請求類型,文件路徑等
   request.responseType = 'arraybuffer'; //配置數據返回類型
   // 一旦獲取完成,對音頻進行進一步操作,比如解碼
   request.onload = function() {
     var arraybuffer = request.response;
  }
  request.send();
 }

2.通過文件類型的input來進行文件選擇,監聽input的onchnage事件,一擔文件選中便開始在代碼中進行獲取處理,此法方便,且不需要工作在服務器上

3.通過拖拽的形式把文件拖放到頁面進行獲取,比前面一種方法稍微繁雜一點(要監聽'dragenter','dragover','drop'等事件)但同樣可以很好地在本地環境下工作,無需服務器支持。

不用說,方法2和3方便本地開發與測試,所以我們兩種方法都實現,既支持選擇文件,也支持文件拖拽。

(1)通過選擇獲取
  在頁面放一個file類型的input。然后在JavaScript中監聽它的onchange事件。此事件在input的值發生變化時觸發。
對於onchange事件,在Chrome與Firefox中還有一點小的區別,如果你已經選擇了一個文件,此時Input就有值了,如果你再次選擇同一文件,onchange事件不會觸發,但在Firefox中該事件會觸發。這里只是提及一下,關系不大。
 <label for="uploadedFile">Drag&drop or select a file to play:</label>
 <input type="file" id="uploadedFile"></input>

  當然,這里同時也把最后我們要畫圖用的canvas也一起放上去吧,后面就不用多話了。所以下面就是最終的HTML了,頁面基本不會變,大量的工作是在JavaScript的編寫上。
 <div id="wrapper">
  <div id="fileWrapper" class="file_wrapper">
   <div id="info">
     HTML5 Audio API showcase | An Audio Viusalizer
   </div>
   <label for="uploadedFile">Drag&drop or select a file to play:</label>
   <input type="file" id="uploadedFile"></input>
  </div>
 <div id="visualizer_wrapper">
  <canvas id='canvas' width="800" height="350"></canvas>
 </div>
</div>
再稍微寫一點樣式:
  #fileWrapper {
   transition: all 0.5s ease;
  }
  #fileWrapper: hover {
   opacity: 1!important;
  }
  #visualizer_wrapper {
   text-align: center;
  }

向Visualizer對象的原型中新加一個方法,用於監聽文件選擇既前面討論的onchange事件,並在事件中獲取選擇的文件。

_addEventListner: function() {
    var that = this,
    audioInput = document.getElementById('uploadedFile'),
    dropContainer = document.getElementsByTagName("canvas")[0];
    //監聽是否有文件被選中
    audioInput.onchange = function() {
      //這里判斷一下文件長度可以確定用戶是否真的選擇了文件,如果點了取消則文件長度為0
      if (audioInput.files.length !== 0) {
          that.file = audioInput.files[0]; //將文件賦值到Visualizer對象的屬性上
          that.fileName = that.file.name;
          that._start(); //獲取到文件后,開始程序,這個方法會在后面定義並實現
      };
   };
 }
上面代碼中,我們假設已經寫好了一個進一步處理文件的方法_start(),在獲取到文件后賦值給Visualizer對象的file屬性,之后在 _start()方法里我們就可以通過訪問this.file來得到該文件了,當然你也可以直接讓_start()方法接收一個file參數,但將文件賦 值到Visualizer的屬性上的好處之一是我們可以在對象的任何方法中都能獲取該文件 ,不用想怎么用參數傳來傳去。同樣,將文件名賦值到Visualizer的fileName屬性當中進行保存,也是為了方便之后在音樂播放過程中顯示當前 播放的文件。

(2)通過拖拽獲取
我們把頁面中的canvas作為放置文件的目標,在它身上監聽拖拽事件'dragenter','dragover','drop'等。
還是在上面已經添加好的_ addEventListner方法里,接着寫三個事件監聽的代碼。
dropContainer.addEventListener("dragenter", function() {
    that._updateInfo('Drop it on the page', true);
}, false);
dropContainer.addEventListener("dragover", function(e) {
    e.stopPropagation();
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy'; //設置文件放置類型為拷貝
}, false);
dropContainer.addEventListener("dragleave", function() {
    that._updateInfo(that.info, false);
}, false);
dropContainer.addEventListener("drop", function(e) {
    e.stopPropagation();
    e.preventDefault();
    that.file = e.dataTransfer.files[0]; //獲取文件並賦值到Visualizer對象
    that.fileName = that.file.name;
    that._start();
}, false);

注意到上面代碼中我們在'dragover'時設置文件拖放模式為'copy',既以復制的形式獲取文件,如果不進行設置無法正確獲取文件

然后在'drop'事件里,我們獲得文件以進行一下步操作。

用FileReader讀取文件為ArrayBuffer

   下面來看這個_start()方法,現在文件得到了,但首先需要將獲取的文件轉換為ArrayBuffer格式,才能夠傳遞給 AudioContext進行解碼,所以接下來_start()方法中要干的事情就是實例化一個FileReader來將文件讀取為 ArrayBuffer格式。

_start: function() {
    //read and decode the file into audio array buffer
    var that = this, //當前this指代Visualizer對象,賦值給that以以便在其他地方使用
    file = this.file, //從Visualizer對象上獲取前面得到的文件
    fr = new FileReader(); //實例化一個FileReader用於讀取文件
    fr.onload = function(e) { //文件讀取完后調用此函數
      var fileResult = e.target.result; //這是讀取成功得到的結果ArrayBuffer數據
      var audioContext = that.audioContext; //從Visualizer得到最開始實例化的AudioContext用來做解碼ArrayBuffer
      audioContext.decodeAudioData(fileResult,function(buffer){//解碼成功則調用此函數,參數buffer為解碼后得到的結果
         that._visualize(audioContext, buffer); //調用_visualize進行下一步處理,此方法在后面定義並實現
      }, function(e) { //這個是解碼失敗會調用的函數
           console.log("!哎瑪,文件解碼失敗:(");
      });
    };
    //將上一步獲取的文件傳遞給FileReader從而將其讀取為ArrayBuffer格式
    fr.readAsArrayBuffer(file);
}

   注意這里我們把this賦值給了that,然后再 audioContext.decodeAudioData的回調函數中使用that來指代我們的Visualizer對象。這是因為作用域的原因。我們 知道JavaScript中無法通過花括號來創建代碼塊級作用域,而唯一可以創建作用域的便是函數。一個函數就是一個作用域。函數內部的this指向的對 象要視情況而定,就上面的代碼來說,它是audioContext。所以如果想要在這個回調函數中調用Visualizer身上方法或屬性,則需要通過另 一個變量來傳遞,這里是that,我們通過將外層this(指向的是我們的Viusalizer對象)賦值給新建的局部變量that,此時that便可以 傳遞到內層作用域中,而不會與內層作用域里面原來的this相沖突。像這樣的用法在源碼的其他地方也有使用,細心的你可以下載本文的源碼慢慢研究。

   所以,在 audioContext.decodeAudioData的回調函數里,當解碼完成得到audiobuffer文件(buffer參數)后,再把 audioContext和buffer傳遞給Visualizer的_visualize()方法進一步處理:播放音樂和繪制頻譜圖。當然此時 _visualize()方法還沒有下,下面便開始實現它。

創建Analyser分析器及播放音頻

  上面已經將獲取的文件進行解碼,得到了audio buffer數據。接下來是設置我們的AudioContext以及獲取頻譜能量信息的Analyser節點。向Visualizer對象添加_visualize方法,我們在這個方法里完成這些工作。

播放音頻
   首先將buffer賦值給audioContext。AudioContext只相當於一個容器,要讓它真正豐富起來需要將實際的音樂信息傳遞給它的。也就是將audio buffer數據傳遞給它的BufferSource屬性。

其實到了這里你應該有點暈了,不過沒關系,看代碼就會更明白一些,程序員是理解代碼優於文字的一種生物。
  var audioBufferSouceNode = audioContext.createBufferSource();
  audioBufferSouceNode.buffer = buffer;

就這么兩名,把音頻文件的內容裝進了AudioContext。這時已經可以開始播放我們的音頻了。
   audioBufferSouceNode.start(0);

這里參數是時間,表示從這段音頻的哪個時刻開始播放。注意:在舊版本的瀏覽器里是使用onteOn()來進行播放的,參數一樣,指開始時刻。

   但此時是聽不到聲音的,因為還差一步,需要將audioBufferSouceNode連接到audioContext.destination,這個AudioContext的destination也就相關於speaker(揚聲器)。
  audioBufferSouceNode.connect(audioContext.destination);
  audioBufferSouceNode.start(0);

此刻就能夠聽到揚聲器傳過來動聽的聲音了。
  _visualize: function(audioContext, buffer) {
    var audioBufferSouceNode = audioContext.createBufferSource();
    audioBufferSouceNode.connect(audioContext.destination);
    audioBufferSouceNode.buffer = buffer;
    audioBufferSouceNode.start(0);
  }


創建分析器
創建獲取頻譜能量值的analyser節點。
  var analyser = audioContext.createAnalyser();
上面一步我們是直接將audioBufferSouceNode與audioContext.destination相連的,音頻就直接輸出到揚聲器開始 播放了,現在為了將音頻在播放前截取,所以要把analyser插在audioBufferSouceNode與 audioContext.destination之間。明白了這個道理,代碼也就很簡單了,audioBufferSouceNode連接到 analyser,analyser連接destination。
  audioBufferSouceNode.connect(analyser);
  analyser.connect(audioContext.destination);

然后再開始播放,此刻所有音頻數據都會經過analyser,我們再從analyser中獲取頻譜的能量信息,將其畫出到Canvas即可。

假設我們已經寫好了畫頻譜圖的方法_drawSpectrum(analyser);
 _visualize: function(audioContext, buffer) {
   var audioBufferSouceNode = audioContext.createBufferSource(),
   analyser = audioContext.createAnalyser();
   //將source與分析器連接
   audioBufferSouceNode.connect(analyser);
  //將分析器與destination連接,這樣才能形成到達揚聲器的通路
   analyser.connect(audioContext.destination);
   //將上一步解碼得到的buffer數據賦值給source
   audioBufferSouceNode.buffer = buffer;
  //播放
   audioBufferSouceNode.start(0);
   //音樂響起后,把analyser傳遞到另一個方法開始繪制頻譜圖了,因為繪圖需要的信息要從analyser里面獲取
   this._drawSpectrum(analyser);
  }

繪制精美的頻譜圖

  接下來的工作,也是最后一步,也就是實現_drawSpectrum()方法,將跟隨音樂而靈動的柱狀頻譜圖畫出到頁面。
繪制柱狀能量槽
   首先你要對數字信號處理有一定了解,嚇人的,不了解也沒多大關系。頻譜反應的是聲音各頻率上能量的分布,所以叫能量槽也沒有硬要跟游戲聯系起來的嫌疑,是 將輸入的信號經過傅里葉變化得到的(大學里的知識終於還是可以派得上用場了)。但特么我知道這些又怎樣呢,僅僅為了裝逼顯擺而以。真實的頻譜圖是頻率上連 續的,不是我們看到的最終效果那樣均勻分開鋸齒狀的。

通過下面的代碼我們可以從analyser中得到此刻的音頻中各頻率的能量值。
  var array = new Uint8Array(analyser.frequencyBinCount);
  analyser.getByteFrequencyData(array);

此刻array中存儲了從低頻0Hz到高頻~Hz的所有數據。頻率做為X軸,能量值做為Y軸,我們可以得到類似下面的圖形。



所以,比如array[0]=100,我們就知道在x=0處畫一個高為100單位長度的長條,array[1]=50,然后在x=1畫一個高為50單位長度的柱條,從此類推,如果用一個for循環遍歷array將其全部畫出的話,便是你看到的上圖。
采樣

但我們要的不是那樣的效果,我們只需在所有數據中進行抽樣,比如設定一個步長100,進度抽取,來畫出整個頻譜圖中的部分柱狀條。

   或者先根據畫面的大小,設計好每根柱條的寬度,以及他們的間隔,從而計算出畫面中一共需要共多少根,再來推算出這個采樣步長該取多少,本例便是這樣實現的。說來還是有點暈,下面看簡單的代碼:

 var canvas = document.getElementById('canvas'),
 meterWidth = 10, //能量條的寬度
 gap = 2, //能量條間的間距
 meterNum = 800 / (10 + 2); //計算當前畫布上能畫多少條
 var step = Math.round(array.length / meterNum); //計算從analyser中的采樣步長

  我們的畫布即Canvas寬800px,同時我們設定柱條寬10px , 柱與柱間間隔為2px,所以得到meterNum為總共可以畫的柱條數。再用數組總長度除以這個數目就得到采樣的步長,即在遍歷array時每隔step 這么長一段我們從數組中取一個值出來畫,這個值為array[i*step]。這樣就均勻地取出meterNum個值,從而正確地反應了原來頻譜圖的形 狀。

 var canvas = document.getElementById('canvas'),
 cwidth = canvas.width,
 cheight = canvas.height - 2,
 meterWidth = 10, //能量條的寬度
 gap = 2, //能量條間的間距
 meterNum = 800 / (10 + 2), //計算當前畫布上能畫多少條
 ctx = canvas.getContext('2d'),
 array = new Uint8Array(analyser.frequencyBinCount);
 analyser.getByteFrequencyData(array);
 var step = Math.round(array.length / meterNum);計算從analyser中的采樣步長
 ctx.clearRect(0, 0, cwidth, cheight); //清理畫布准備畫畫
 //定義一個漸變樣式用於畫圖
 gradient = ctx.createLinearGradient(0, 0, 0, 300);
 gradient.addColorStop(1, '#0f0');
 gradient.addColorStop(0.5, '#ff0');
 gradient.addColorStop(0, '#f00');
 ctx.fillStyle = gradient;
 //對信源數組進行抽樣遍歷,畫出每個頻譜條
 for (var i = 0; i < meterNum; i++) {
   var value = array[i * step];
   ctx.fillRect(i * 12 /*頻譜條的寬度+條間間距*/ , cheight - value + capHeight, meterWidth, cheight);
 }

使用requestAnimationFrame讓柱條動起來

  但上面繪制的僅僅是某一刻的頻譜,要讓整個畫面動起來,我們需要不斷更新畫面,window.requestAnimationFrame()正好提供了更新畫面得到動畫效果的功能,這里直接給出簡單改造后的代碼,即得到我們要的效果了:跟隨音樂而靈動的頻譜柱狀圖。

var canvas = document.getElementById('canvas'),
    cwidth = canvas.width,
    cheight = canvas.height - 2,
    meterWidth = 10, //能量條的寬度
    gap = 2, //能量條間的間距
    meterNum = 800 / (10 + 2), //計算當前畫布上能畫多少條
    ctx = canvas.getContext('2d');
    //定義一個漸變樣式用於畫圖
    gradient = ctx.createLinearGradient(0, 0, 0, 300);
    gradient.addColorStop(1, '#0f0');
    gradient.addColorStop(0.5, '#ff0');
    gradient.addColorStop(0, '#f00');
    ctx.fillStyle = gradient;
    var drawMeter = function() {
      var array = new Uint8Array(analyser.frequencyBinCount);
      analyser.getByteFrequencyData(array);
      var step = Math.round(array.length / meterNum); //計算采樣步長
      ctx.clearRect(0, 0, cwidth, cheight); //清理畫布准備畫畫
      for (var i = 0; i < meterNum; i++) {
        var value = array[i * step];
        ctx.fillRect(i * 12 /*頻譜條的寬度+條間間距*/ , cheight - value + capHeight, meterWidth, cheight);
     }
     requestAnimationFrame(drawMeter);
  }
  requestAnimationFrame(drawMeter);

繪制緩慢降落的帽頭
  到上面一步,主要工作已經完成。最后為了美觀,再實現一下柱條上方緩慢降落的帽頭。
原理也很簡單,就是在繪制柱條的同時在同一X軸的位置再繪制一個短的柱條,並且其開始和結束位置都要比頻譜中的柱條高。難的地方便是如何實現緩慢降落。

   首先要搞清楚的一點是,我們拿一根柱條來說明問題,當此刻柱條高度高於前一時刻時,我們看到的是往上沖的一根頻譜,所以這時帽頭是緊貼着正文 柱條的,這個好畫。考慮相反的情況,當此刻高度要低於前一時刻的高度時,下方柱條是立即縮下去的,同時我們需要記住上一時刻帽頭的高度位置,此刻畫的時候 就按照前一時刻的位置將Y-1來畫。如果下一時刻頻譜柱條還是沒有超過帽頭的位置,繼續讓它下降,Y-1畫出帽頭。

   通過上面的分析,所以我們在每次畫頻譜的時刻,需要將此刻頻譜及帽頭的Y值(即垂直方向的位置)記到一個循環外的變量中,在下次繪制的時刻從這個變量中讀取,將此刻的值與變量中保存的上一刻的值進行比較,然后按照上面的分析作圖。

最后給出實現的代碼:

_drawSpectrum: function(analyser) {
    var canvas = document.getElementById('canvas'),
        cwidth = canvas.width,
        cheight = canvas.height - 2,
        meterWidth = 10, //頻譜條寬度
        gap = 2, //頻譜條間距
        capHeight = 2,
        capStyle = '#fff',
        meterNum = 800 / (10 + 2), //頻譜條數量
        capYPositionArray = []; //將上一畫面各帽頭的位置保存到這個數組
    ctx = canvas.getContext('2d'),
    gradient = ctx.createLinearGradient(0, 0, 0, 300);
    gradient.addColorStop(1, '#0f0');
    gradient.addColorStop(0.5, '#ff0');
    gradient.addColorStop(0, '#f00');
    var drawMeter = function() {
        var array = new Uint8Array(analyser.frequencyBinCount);
        analyser.getByteFrequencyData(array);
        var step = Math.round(array.length / meterNum); //計算采樣步長
        ctx.clearRect(0, 0, cwidth, cheight);
        for (var i = 0; i < meterNum; i++) {
            var value = array[i * step]; //獲取當前能量值
            if (capYPositionArray.length < Math.round(meterNum)) {
                capYPositionArray.push(value); //初始化保存帽頭位置的數組,將第一個畫面的數據壓入其中
            };
            ctx.fillStyle = capStyle;
            //開始繪制帽頭
            if (value < capYPositionArray[i]) { //如果當前值小於之前值
    ctx.fillRect(i *12,cheight-(--capYPositionArray[i]),meterWidth,capHeight);//則使用前一次保存的值來繪制帽頭
            } else {
                ctx.fillRect(i * 12, cheight - value, meterWidth, capHeight); //否則使用當前值直接繪制
                capYPositionArray[i] = value;
            };
            //開始繪制頻譜條
            ctx.fillStyle = gradient;
            ctx.fillRect(i * 12, cheight - value + capHeight, meterWidth, cheight);
        }
        requestAnimationFrame(drawMeter);
    }
    requestAnimationFrame(drawMeter);
}

轉載:http://www.108js.com/article/article7/70196.html?id=983


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM