JavaScript 使用 Web Audio 和 Canvas 設計音樂韻律動畫

在開發過程遇到了一個對我來說特別有趣的應用,藉由這次的開發來介紹相關的功能應用,這個項目是利用擷取音樂波形數據來設計與音樂播放的同時並轉換視覺化動態效果,以往在 Flash 應用時期常常會利用音訊及動畫製作許多開發,如今 HTML5 對於裝置硬體訪問以及動畫的設計也能輕鬆的訪問及制作複雜的動畫。

接下來開始描述這個項目建構,並一同了解每個相關的工作原理,在最後提供完整的示例。

需要的相關工具

  • Web Audio API
  • Canvas API
  • jQuery library
  • CreateJS library

在這個項目中使用是 HTML5 提供 Web Audio API 以及 Canvas API 作為我們的主要應用,在使用 jQuery 使某些操作更容易完成,以及藉由 CreateJS 函式庫來建構精彩的動畫。

基本設定

建立一個基本的 HTML 頁面,它加載了這次項目會使用的 2 個函式庫,以及我們在項目中需要的 CSS 規則的樣式表。

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title></title>
    <style>
        body{
            background-color: #000;
        }
        #upload {
            position: fixed;
            top: 10px;
            left: 10px;
            z-index: 100;
        }
        #canvas {
            position: fixed;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
        }
        audio {
            position: fixed;
            left: 10px;
            bottom: 10px;
            width: calc(100% - 20px);
        }
    </style>
</head>
<body>
<input type="file" id="upload" accept="audio/*"/>
<canvas id="canvas"></canvas>
<audio id="audio" controls></audio>
<script src="//ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="//code.createjs.com/1.0.0/createjs.min.js"></script>
<script>
    (function ($, createjs) {
        ....
    })(jQuery, createjs);
</script>
</body>
</html>

聲音數據處理

首先使用 jQuery 註冊 input[type="file"]  上傳事件來取得使用者上傳的音檔並播放。

let audioElement = $('#audio')[0];

$(document).ready(function () {
    $('#upload').change(function () {
        const input = this;

        if (input.files && input.files[0]) {
            audioElement.src = URL.createObjectURL(input.files[0]);
            audioElement.load();
            audioElement.play();
        }
    });
});

現在我們已經可獲得所需要的音檔文件源,可以開始連接分析聲音的數據。

連接音訊的處理容器

所先需要建立一個 AudioContext 容器來幫助我們管理所有聲音,單個 AudioContext 可以支持多個聲音輸入,因此我們在單個應用中只需要初始化一次,就可以對多個不同的聲音來源同時使用。

try {
    window.AudioContext = window.AudioContext||window.webkitAudioContext;

    let audioCtx = new AudioContext();
} catch(e) {
    alert('Web Audio API is not supported in this browser');
}

如果使用 Safari,則需要使用 webkitAudioContext 而不是 AudioContext。

接下來,建立一個 AnalyserNode,它會為我們提供用於實現視覺效果的頻率原始數據。

let analyser = audioCtx.createAnalyser();

設置 AnalyserNode fftSize 屬性值,這將告訴 AnalyserNode 傳回給我們的訊號數據數組應該有多大,另外要注意的是必須給它一個 2 的根值。

analyser.fftSize = 128;

最後再把我們 HTML 所用的 audio element 利用 AudioContext 提供的 createMediaElementSource 方法將它建立為 MediaElementAudioSourceNode 。

const source = audioCtx.createMediaElementSource(audioElement);

現在我們需要將所有 Node 連接在一起,以便它們可以讀取彼此的數據。

const source = audioCtx.createMediaElementSource(audioElement);

source.connect(analyser);
source.connect(audioCtx.destination);

循環讀取音訊數據

建立並連接所有 Node 後,我們建立一個長度為 frequencyBinCount (frequencyBinCount 是 fftSize 數量的一半) 空的 Uint8 陣列,作為未來讀取數據的容器。

let freqData = new Uint8Array(analyser.frequencyBinCount);

現在一切都準備就緒,可以開始我們的循環讀取所需要的數據。

首先,為我們開發項目構建自定義事件與監聽器,作為數據更新時可方便管理將數據發送給所有我們項目內所需要這些數據的程式。

let listeners = [];

function registerAudioListener(listener) {
    listeners.push(listener);
}

function audioListener(data) {
    listeners.forEach(l => l(data));
}

利用 requestAnimationFrame,它做的事情很簡單,就是在大約 1/60 秒後呼叫傳給它的 Callback function,並且用當時的「高精度」timestamp 當做參數傳給這個 Callback。

requestAnimationFrame(tick);

Callback function 我們需要利用 analyserNode 取得當下的音頻訊號來填充我們的剛剛所建立數據數組,為了循環我們必須在 function 內部裡面再次調用 requestAnimationFrame

function tick() {
    analyser.getByteFrequencyData(freqData);
    audioListener(freqData);
    requestAnimationFrame(tick);
}

可視化數據的呈現

現在我們可以進入有趣的部分,既然我們有了數據,終於可以用這些數據進行繪製,我們將使用 JavaScript Canvas API 與 CreateJS 將圖形繪製到 HTML 元素中。

初始化 Canvas

首先,我們建立一個初始化的 function 在畫面載入時使用,當我們還未取得任何音訊源時,我們先將音訊的數據填充數據,另外這裡填充的數據是 128,是為了讓視覺效果是對等的呈現,實際示例的項目擷取的音訊數據還是為 64,在稍後會在說明數據的處理。

let transitionAudioData = [];

function init() {
    $("#canvas").attr({
        width: $(document).width(),
        height: $(document).height()
    });

    for(let i = 0; i < 128; i++) {
        transitionAudioData.push(0);
    }

        ....
}

建立 Canvas 的場景創建了一個新的 Stage 對象,為了優化性能我們將啟用 snapToPixel,這樣做會導致運動(每像素)的平滑度有所下降,但會提高 FPS 並降低 CPU 負載。

stage = new createjs.Stage("canvas");

stage.snapToPixel = true;
stage.snapToPixelEnabled = true;

使用 createjs.Ticker 事件來更新場景,通過 addEventListener 方法中 tick 監聽事件來定期執行更新我們在音訊所得到的數據並繪製出來。

預設情況下,tick 事件每秒發生 24 次,但可以使用 setFPS 方法指定更改此頻率,每秒將被調用 60 次(大約),但如如果當您達到 CPU 的限制時,FPS 則 會下降。

createjs.Ticker.setFPS(60);
createjs.Ticker.addEventListener("tick", _.draw);

將數據繪製成線條

先定義兩個會使用的 color interpolation function,用來達到我們項目中每個線條顏色漸變的效果,我們將指定兩個 RGB 的顏色並在顏色中間已給定的數量來產生一系列的漸變顏色,再利用 getLineColors 方法生成這項目中所需要的 128 個線條顏色。

function getLineColors(){
    const colorgroup = [
        ['rgb(211, 212, 214)','rgb(170, 209, 225)'],
        ['rgb(51, 110, 168)','rgb(120, 67, 188)'],
        ['rgb(145, 58, 158)', 'rgb(204, 53, 131)'],
        ['rgb(215, 109, 79)','rgb(201, 143, 89)']
    ];

    let colors = [];

    for (let i = 0; i < colorgroup.length; i++){
        let _c = interpolateColors(colorgroup[i][0], colorgroup[i][1], transitionAudioData.length / colorgroup.length);

        for (let v = 0; v < _c.length; v++){
            colors.push(_c[v].join(","));
        }
    }

    return colors;
}

function interpolateColor(color1, color2, factor) {
    if (arguments.length < 3) {
        factor = 0.5;
    }
    let result = color1.slice();
    for (let i = 0; i < 3; i++) {
        result[i] = Math.round(result[i] + factor * (color2[i] - color1[i]));
    }
    return result;
}

function interpolateColors(color1, color2, steps) {
    let stepFactor = 1 / (steps - 1),
        interpolatedColorArray = [];

    color1 = color1.match(/\d+/g).map(Number);
    color2 = color2.match(/\d+/g).map(Number);

    for(var i = 0; i < steps; i++) {
        interpolatedColorArray.push(interpolateColor(color1, color2, stepFactor * i));
    }

    return interpolatedColorArray;
}

接下來建立 draw 的方法將所有的數據排列繪製呈現。

一開始調用 stage.removeAllChildren 當每次更新場景時就清除場景上所有的物件,並再以全新的數據組重新繪製線條,並依照所需要的設計效果定義變量來控制線條的寬度、高度、間距和顏色。

function draw() {
    stage.removeAllChildren();

    const spacing = 10;
    const lineWidth = 5;
    const lineHeightMultiplier = .5;

    const totalWidth = transitionAudioData.length * spacing - spacing;
    const offsetX = (stage.canvas.width - totalWidth) / 2;
    const offsetY = stage.canvas.height / 2;
    const colors = getLineColors();

    for (let x = 0; x < transitionAudioData.length; x++) {
        const audioValue = transitionAudioData[x];
        const lineHeight = audioValue * lineHeightMultiplier;
        const line = new createjs.Shape();

        line.graphics.setStrokeStyle(lineWidth, "round")
        line.graphics.beginStroke("rgb("+colors[x]+")");

        line.graphics.moveTo(x * spacing + offsetX, -lineHeight + offsetY);
        line.graphics.lineTo(x * spacing + offsetX, lineHeight + offsetY);

        stage.addChild(line);
    }

    stage.update();
}

註冊音訊數據的監聽

最後回到 init 方法中,使用 registerAudioListener 自定義事件來取得音訊數據更新至 Canvas 所存取的數組中,這邊會分兩段更新數據組是因為保留原始數據並為效果所需要的數據再次轉換。

在下面 Callback 的方法中我們事先使用 correctWithPinkNoiseResults 方法進行轉換一次,再將數據組複製一份進行翻轉,再將兩組數據進行合併,讓呈現的視覺效果上達到對稱的動態效果。

registerAudioListener(function(data) {
    const newAudioData = getPinkNoiseResults(data);
    const reverse_ay = newAudioData.slice();
    const _newAudioData = reverse_ay.reverse().concat(newAudioData);

    if (transitionAudioData.length === _newAudioData.length) {
        createjs.Tween.get(transitionAudioData, {
            override: true
        }).to(_newAudioData, 50);
    } else {
        transitionAudioData = _newAudioData;
    }
});

聲音頻率校正

Web Audio API 擷取得每個數據音量總是從 0255 的值,當我們稍微將每個數值做調整,視覺效果所呈現的方式又會更加完美,所以才會將原始數據組分開來並建立 getPinkNoiseResults 方法來依照所需要的效果再次校正。

function getPinkNoiseResults(data) {
    let data2 = [];
    let pinkNoise = [0.7060367470305, 0.85207379418243, 0.68842437227852, 0.63767902570829, 0.5452348949654, 0.50723325864167, 0.4677726234682, 0.44204182748767, 0.41956517802157, 0.41517375040002, 0.41312118577934, 0.40618363960446, 0.39913707474975, 0.38207008614508, 0.38329789106488, 0.37472136606245, 0.36586428412968, 0.37603017335105, 0.39762590761573, 0.39391828858591, 0.37930603769622, 0.39433365764563, 0.38511504613859, 0.39082579241834, 0.3811852720504, 0.40231453727161, 0.40244151133175, 0.39965366884521, 0.39761103827545, 0.51136400422212, 0.66151212038954, 0.66312205226679, 0.7416276690995, 0.74614971301133, 0.84797007577483, 0.8573583910469, 0.96382997811663, 0.99819377577185, 1.0628692615814, 1.1059083969751, 1.1819808497335, 1.257092297208, 1.3226521464753, 1.3735992532905, 1.4953223705889, 1.5310064942373, 1.6193923584808, 1.7094805527135, 1.7706604552218, 1.8491987941428, 1.9238418849406, 2.0141596921333, 2.0786429508827, 2.1575522518646, 2.2196355526005, 2.2660112509705, 2.320762171749, 2.3574848254513, 2.3986127976537, 2.4043566176474, 2.4280476777842, 2.3917477397336, 2.4032522546622, 2.3614180150678,];

    for (var i = 0; i < 64; i++) {
        data2[i] = data[i] / pinkNoise[i];
        data2[i] = data[i] / pinkNoise[i];
    }
    return data2;
}

整體設計的視化音樂:

2022-01-09 22.44.22.gif

完整的範例:


See the Pen Web Audio by Eric Wang (@wweitinio) on CodePen
1