[JavaScript] Web AudioでSoundCloudにあるみたいな波形を描画したい

2018/09/21

こんにちは。きんくまです。
最近 Web Audioについて調べていました。

今回は、Web AudioでSoundCloudにあるみたいな波形を描画したいです。
どんなやつかといいますと、こんなやつです。

簡単にやりたい

一番簡単なのは、wavesurfer.jsというライブラリを使うことです。
今回の記事はこのwavesurferのソースを参考にさせていただき、自前で作ります。

>> wavesurfer.js

>> katspaugh/wavesurfer.js: Navigable waveform built on Web Audio and Canvas

手順

1. ajaxで音ファイルを読み込み
2. decodeAudioDataでAudioBufferを取得
3. Float32Arrayの配列から特定の区間ごとにサンプルの最大値(Peak)を求める
4. Canvasで描画

Web Audioの入門はMDNを読むとわかりやすかったです。
今度入門記事(といってもほとんどMDNのまま)を書きたいと思います。

>> Web Audio API – Web APIs | MDN

1. ajaxで音ファイルを読み込み

まずはajaxで音ファイルを読み込みます。
今回 PeakAnalyzer というクラスを作成しました。このクラスは上の手順でいうところの 1 – 3 を行うものです。

export default class PeakAnalyzer {

    audioCtx = new AudioContext();
    promise = null;

    constructor(){

    }

    /**
     * 音ファイルからPeakを取得します
     * @param url 分析する音ファイル
     * @param peakLength 欲しいpeakの配列の長さ
     * @return {*}
     */
    analyze(url, peakLength){
        this.promise = new Promise((resolve, reject)=>{
            const req = new XMLHttpRequest();
            req.open('GET', url, true);
            req.responseType = 'arraybuffer';
            req.onload = ()=>{
                if(req.status === 200){
                    this.onLoadSound(req.response, peakLength, resolve, reject);
                }else{
                    reject(req.statusText);
                }
            };
            req.send();
        });
        return this.promise;
    }

音ファイルを読み込むと、ArrayBufferが返ってきます。req.responseの中身です。

このままだと単なるデータの配列なので、これをWebAudioで使える形にします。 decodeAudioData で AudioBuffer に変換します。

2. decodeAudioDataでAudioBufferを取得

    onLoadSound(audioData, peakLength, resolve, reject){

        this.audioCtx.decodeAudioData(audioData).then((buffer)=>{

            const ch1 = buffer.getChannelData(0);
            const peaks1 = this.getPeaks(ch1, peakLength);

            const ch2 = buffer.getChannelData(1);
            const peaks2 = this.getPeaks(ch2, peakLength);

            resolve([peaks1, peaks2]);

        }).catch((error)=>{
            reject(error);
        });
    }

このあたりは、以下のMDNに書いてあります。

>> BaseAudioContext.decodeAudioData()

それで、今回はステレオの音ファイルなので、AudioBufferからチャンネルごとにpeakをもとめることにしました。
getChannelDataのあたりです。

3. Float32Arrayの配列から特定の区間ごとにサンプルの最大値(Peak)を求める

getChannelDataから出てくるのは Float32Array の配列です。
-1 〜 1 までの値が入っています。

ここから、区間ごとに最大値を求めて、それを新規配列に詰めます。

    getPeaks(array, peakLength){
        let step;
        if(!peakLength){
            peakLength = 9000;
        }

        step = Math.floor(array.length / peakLength);

        if(step < 1){
            step = 1;
        }
        let peaks = [];
        for(let i = 0, len = array.length; i < len; i += step){
            const peak = this.getPeak(array, i, i + step);
            peaks.push(peak);
        }
        return peaks;
    }

    getPeak(array, startIndex, endIndex){
        const sliced = array.slice(startIndex, endIndex);
        let peak = -100;
        for(let i = 0, len = sliced.length; i < len; i++){
            const sample = sliced[i];
            if(sample > peak){
                peak = sample;
            }
        }
        return peak;
    }

なんで区間ごとに区切るのかといいますと、Float32Arrayから音サンプルを間引いているといいますかそんな感じです。
Canvasで波形を描画するには、サンプル数がものすごく多すぎます。なので、Canvasでいい感じに描画できるように音を間引いています。

これでpeakの配列が求まりました。

4. Canvasで描画

そうしたらCanvasで描画してみましょう!

今回Vue.jsの中で描画していますが、Vueの中じゃない普通のCanvasでも、もちろん全く問題ないです。

import PeakAnalyzer from "./models/PeakAnalyzer";

export default{
    data(){
        return{
            analyzer:null,
            canvas:null
        }
    },
    components:{

    },
    mounted(){
        this.canvas = document.querySelector('.web-audio-sample .waveform');

        this.analyzer = new PeakAnalyzer();
        let fileUrl;
        fileUrl = "sounds/sound_filename.mp3";

        this.analyzer.analyze(fileUrl, this.canvas.width * 10)
            .then((peaksArr)=>{
                this.drawWaveform(peaksArr[0], peaksArr[1]);
            }).catch((err)=>{
                console.error(err);
            });
    },
    methods:{
        drawWaveform(ch1, ch2){
            const canvas = this.canvas;
            const ctx = canvas.getContext('2d');
            const barMargin = 0;
            const barWidth = canvas.width / ch1.length - barMargin;
            const canvasH = canvas.height;
            const halfCanvasH = canvasH / 2;
            ctx.fillStyle = '#1355a5';
            let sample;
            let barHeight;
            for(let i = 0, len = ch1.length; i < len; i++){
                //ch1
                sample = ch1[i];
                barHeight = sample * halfCanvasH;
                ctx.fillRect(i * (barWidth + barMargin), halfCanvasH - barHeight, barWidth, barHeight);

                //ch2
                sample = ch2[i];
                barHeight = sample * halfCanvasH;
                ctx.fillRect(i * (barWidth + barMargin), halfCanvasH, barWidth, barHeight);
            }
        }
    }
}

実行結果

無事に波形が描画されました!! waveform.jsとほとんど同じ結果になりました。

あとは、描画したい波形をイメージしてパラメータを調整してあげれば、SoundCloudみたいなこんな感じのやつもできます。

おまけ PeakAnalyzerクラス

本文ではぶつぶつと途切れていましたが、PeakAnalyzerクラスの全体です。

ちなみに、最初decodeするのに OfflineAudioContext を使っていたのですが、音を特に加工しない分には普通の AudioContext で問題ないみたいです。

export default class PeakAnalyzer {

    audioCtx = new AudioContext();
    promise = null;

    constructor(){

    }

    /**
     * 音ファイルからPeakを取得します
     * @param url 分析する音ファイル
     * @param peakLength 欲しいpeakの配列の長さ
     * @return {*}
     */
    analyze(url, peakLength){
        this.promise = new Promise((resolve, reject)=>{
            const req = new XMLHttpRequest();
            req.open('GET', url, true);
            req.responseType = 'arraybuffer';
            req.onload = ()=>{
                if(req.status === 200){
                    this.onLoadSound(req.response, peakLength, resolve, reject);
                }else{
                    reject(req.statusText);
                }
            };
            req.send();
        });
        return this.promise;
    }

    onLoadSound(audioData, peakLength, resolve, reject){

        this.audioCtx.decodeAudioData(audioData).then((buffer)=>{

            const ch1 = buffer.getChannelData(0);
            const peaks1 = this.getPeaks(ch1, peakLength);

            const ch2 = buffer.getChannelData(1);
            const peaks2 = this.getPeaks(ch2, peakLength);

            resolve([peaks1, peaks2]);

        }).catch((error)=>{
            reject(error);
        });
    }

    getPeaks(array, peakLength){
        let step;
        if(!peakLength){
            peakLength = 9000;
        }

        step = Math.floor(array.length / peakLength);

        if(step < 1){
            step = 1;
        }
        let peaks = [];
        for(let i = 0, len = array.length; i < len; i += step){
            const peak = this.getPeak(array, i, i + step);
            peaks.push(peak);
        }
        return peaks;
    }

    getPeak(array, startIndex, endIndex){
        const sliced = array.slice(startIndex, endIndex);
        let peak = -100;
        for(let i = 0, len = sliced.length; i < len; i++){
            const sample = sliced[i];
            if(sample > peak){
                peak = sample;
            }
        }
        return peak;
    }
}
LINEで送る
Pocket

自作iPhoneアプリ 好評発売中!
フォルメモ - シンプルなフォルダつきメモ帳
ジッピー電卓 - 消費税や割引もサクサク計算!

LINEスタンプ作りました!
毎日使える。とぼけたウサギ。LINEスタンプ販売中! 毎日使える。とぼけたウサギ

ページトップへ戻る