JavaScript
canvas
chrome-extension
WebAudioAPI

こんにちは. FRESH! でフロントエンドエンジニアをしているこりらです.
CyberAgent Developers Advent Calendar 2017 の 16 日目の記事になります.

FIL の日とは ?

FIL とは, FRESH Initiative Laboratory の略で, FRESH! において隔週金曜日に設けられています.

なぜ FIL の日が設定されたのか ?

技術が競争力に直結しやすい事業なのに, 技術投資をやっていかないのはこの先マズいという理由です. 要するに, 目先の技術だけでなく, 中・長期を見据えた技術投資をしていくために設定されました. 対象者は, エンジニア, デザイナー, ディレクターです.

何をする日なのか ?

まとめると, 以下の 2 点になります.

  • 直近の施策とは別に, 数ヶ月先 〜 1 年スパンで確実に必要になっていくであろう技術に対し, 今から少しずつ研究や検証を重ねることで引き出しを増やしておく
  • 普段業務に忙殺されていて, なかなかできなくなっていたことや課題を FIL の日を利用して一掃する. エンジニアであれば, 「技術的負債の解消」, デザイナーであれば, 「Sketch のリファクタリング」 , ディレクターであれば, 「コンフルの整理」 ...etc)

ルール

  • FRESH! に関すること
  • その日取り組むことを宣言し, それだけに一点集中して過ごす
  • その日の結果を簡単なレポートにまとめる 
    • (例) ◯◯ の仕組みを実現するための検証実装ができた
    • (例) ◯◯ ができないかを検証していたが、△△ の手法では難しいことがわかった (できないことがわかるのも収穫)
    • (例) ずっと足かせになっていた ◯◯ の負債を片付けることができた

この 3 つのルールを守れば, 原則, 何をするかはその人の自由です.

FRESH! EQUALIZER

そんな FIL の日を利用して私が実装しているプロダクトが FERSH! EQUALIZER です. これは, FRESH! の番組に, (グラフィック) イコライザー機能をつけることができる Chrome extension です.

fresh-equalizer.png

きっかけ

音楽が好きで, Web Audio API を利用したプロダクトを実装してきた経験がかなりあること (以下は, 1 例です) です. また, FRESH! の業務において Web Audio API を利用する機会がまったくなかったことや, 「音」に注目しているエンジニアがあまりいなかったことです.

XSound
Web Audio API フルスタックライブラリ (日本語 API ドキュメント)
X Sound
XSound を利用したサウンドクリエイトアプリケーション
WEB SOUNDER
Web Audio API 解説サイト

イコライザーとは ?

もともと, アナログで楽曲がレコーディングされていた時代に, 音量が小さくなってしまう周波数帯域の音を等しくする (Equalize) ために利用されていましたが, 現在では, 逆に低音域の音量を大きくしてベースの効いたサウンドにしたり, 高音域の音量を大きくして輪郭のはっきりしたサウンドにしたりといった音を積極的に加工するエフェクターの 1 種として利用されています.

実装

FERSH! EQUALIZER は大きく 3 つの実装で成り立っています.

  • 波形 (スペクトル) を Canvas に描画する処理
  • コントローラー (UI) を Canvas に描画する処理
  • ユーザーの操作 (イベント) によって, イコライザーの値を設定する処理

以下のセクションでは, この 3 つの処理に焦点をあてて実装の概要を解説します.

波形 (スペクトル) を Canvas に描画する処理

実は, この処理の 99% は, 先ほど紹介した自作の Web Audio API のフルスタックライブラリである, XSound が担っています. アプリケーション側で書くコードは, 波形のカラーなどを設定するだけです

const visualizerCanvas = document.getElementById('visualizer-canvas');

visualizerCanvas.width  = window.innerWidth;
visualizerCanvas.height = window.innerHeight;

X('media').module('analyser').domain('fft').setup(visualizerCanvas).state(true).param({
    interval : 'auto',
    shape    : 'rect',
    wave     : '#1a9ebf',
    width    : 1,
    grid     : 'none',
    text     : 'none',
    top      : 0,
    right    : 0,
    bottom   : 0,
    left     : 0
});
#visualizer-canvas {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1001;  /* .topbar { z-index: 1000; } */
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.3);
}

以上です. たったこれだけの記述で, 波形描画 (スペクトル) が可能です. めんどくさい Canvas の座標計算などはすべてライブラリが担っています.

コントローラー (UI) を Canvas に描画する処理

UI の表示のために, UI クラスを実装しました.

'use strict';

class UI {
    constructor(canvas, context) {
        this.canvas  = canvas;
        this.context = context;
    }

    drawCircle(x, y, isMouseDown) {
        const radius = isMouseDown ? 24 : 12;

        this.context.fillStyle = '#dfdfdf';
        this.context.beginPath();
        this.context.arc(Math.floor(x), Math.floor(y), radius, 0, (2 * Math.PI), true);
        this.context.fill();
    }

    drawLine(startX, startY, endX, endY) {
        this.context.lineWidth = 1;
        this.context.strokeStyle = '#fdfdfd';
        this.context.beginPath();
        this.context.moveTo(Math.floor(startX), Math.floor(startY));
        this.context.lineTo(Math.floor(endX), Math.floor(endY));
        this.context.stroke();
    }

    drawCross(text, x, y) {
        const { width : w, height : h } = this.canvas;
        const offset = 24;

        this.context.fillStyle = '#fdfdfd';
        this.context.fillRect(x, h, 1, -(h - offset));
        this.context.fillRect(0, y, w, 1);

        const fontSize = 16;

        this.context.font      = `${fontSize}px 'Lato'`;
        this.context.fillStyle = '#fdfdfd';
        this.context.textAlign = 'center';
        this.context.fillText(text, x, fontSize);
    }
}

描画する UI は,

  • つまみのための円
  • つまみとつまみを結ぶ直線
  • つまみに mouseover したときに表示するクロスとテキストになっています.

そして, 実際に UI を表示するコードです.

// FREQUENCIES は別ファイルで定義されている, 以下のような周波数の配列です
// const FREQUENCIES = [125, 250, 500, 1000, 2000, 4000];

const controllerCanvas = document.getElementById('controller-canvas');

const middle      = Math.floor(controllerCanvas.height / 2);
const fftSize     = X('media').module('analyser').param('fftSize');
const fsDivN      = X.SAMPLE_RATE / fftSize;
const drawnSize   = X('media').module('analyser').domain('fft').param('size');
const f125        = FREQUENCIES[0] / fsDivN;
const f250        = FREQUENCIES[1] / fsDivN;
const f500        = FREQUENCIES[2] / fsDivN;
const f1000       = FREQUENCIES[3] / fsDivN;
const f2000       = FREQUENCIES[4] / fsDivN;
const f4000       = FREQUENCIES[5] / fsDivN;
const widthOfRect = controllerCanvas.width / drawnSize;

let f125X  = Math.floor(widthOfRect * f125);
let f250X  = Math.floor(widthOfRect * f250);
let f500X  = Math.floor(widthOfRect * f500);
let f1000X = Math.floor(widthOfRect * f1000);
let f2000X = Math.floor(widthOfRect * f2000);
let f4000X = Math.floor(widthOfRect * f4000);

let f125Y  = middle;
let f250Y  = middle;
let f500Y  = middle;
let f1000Y = middle;
let f2000Y = middle;
let f4000Y = middle;

const context = controllerCanvas.getContext('2d');

const ui = new UI(controllerCanvas, context);

ui.drawLine(0, f125Y, f125X, f125Y);
ui.drawLine(f125X, f250Y, f250X, f250Y);
ui.drawLine(f250X, f500Y, f500X, f500Y);
ui.drawLine(f500X, f1000Y, f1000X, f1000Y);
ui.drawLine(f1000X, f2000Y, f2000X, f2000Y);
ui.drawLine(f2000X, f4000Y, f4000X, f4000Y);
ui.drawLine(f4000X, middle, controllerCanvas.width, middle);

ui.drawCircle(f125X, f125Y, false);
ui.drawCircle(f250X, f250Y, false);
ui.drawCircle(f500X, f500Y, false);
ui.drawCircle(f1000X, f1000Y, false);
ui.drawCircle(f2000X, f2000Y, false);
ui.drawCircle(f4000X, f4000Y, false);
#controller-canvas {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1002;
    width: 100%;
    height: 100%;
}

x 座標の計算には, それなりの音信号処理に関する知識が必要になるので, 詳細は割愛します. とりあえず, こういう感じで算出しているのかーぐらいに眺めていただければ大丈夫です.

ユーザーの操作 (イベント) によって, イコライザーの値を設定する処理

XSound では, 4 帯域のイコライザーしかサポートしていないので, 6 帯域を制御可能な GraphicEqualizer クラスを実装しました.

'use strict';

const FREQUENCIES = [125, 250, 500, 1000, 2000, 4000];

class GraphicEqualizer {
    constructor(context) {
        this.context = context;

        this.input  = this.context.createGain();
        this.output = this.context.createGain();

        this.filters = new Array(FREQUENCIES.length);

        for (let i = 0, len = this.filters.length; i < len; i++) {
            this.filters[i]                 = this.context.createBiquadFilter();
            this.filters[i].type            = 'peaking';
            this.filters[i].frequency.value = FREQUENCIES[i];
            this.filters[i].Q.value         = 2;
            this.filters[i].gain.value      = 0;
        }

        this.input.connect(this.filters[0]);

        for (let i = 0, len = this.filters.length; i < len; i++) {
            if (i < (len - 1)) {
                this.filters[i].connect(this.filters[i + 1]);
            } else {
                this.filters[i].connect(this.output);
            }
        }
    }

    param(key, value) {
        const k = parseFloat(key);

        const index = FREQUENCIES.indexOf(k);

        if (index === -1) {
            return;
        }

        const v = parseFloat(value);

        const mindB = -40;
        const maxdB =  40;

        if ((v >= mindB) && (v <= maxdB)) {
            this.filters[index].gain.value = v;
        }
    }
}

このクラスを理解するためには, それなりの Web Audio API や音信号処理の知識が必要となるのでここも割愛します. とりあえずは, 6 帯域を制御するためのイコライザーのクラスを定義していると理解していただければ十分です.

そして, この GraphicEqualizer クラスを XSound から扱えるようにします.

const equalizer = new GraphicEqualizer(X.get());

X('media').modules.push(equalizer);

ここも, XSound の優れたところで, ある特定のインターフェースを実装すれば, 上記のようなコードで自作したエフェクターを追加することが可能です.

以上で, イコライザーの値を設定する準備はできました.

あとは, mousemove イベントで, UI の座標を更新して, それに応じて, イコライザーの値を設定するだけです.

let isMouseDown = false;

const onMousedown = () => {
    isMouseDown = true;
};

const onMousemove = event => {
    const x = event.pageX;
    const y = event.pageY;

    const { width : w, height : h } = controllerCanvas;

    const maxdB = 40;
    const rate  = (middle - y) / middle;
    const dB    = rate * maxdB;

    const className = 'onController';

    controllerCanvas.classList.remove(className);

    context.clearRect(0, 0, w, h);

    // Draw controllers

    // 125 Hz
    ui.drawLine(0, middle, f125X, f125Y);
    ui.drawCircle(f125X, f125Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[0] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(0, middle, f125X, y);
            ui.drawCircle(f125X, y, true);

            f125Y = y;

            equalizer.param(FREQUENCIES[0], dB);
        }
    }

    // 250 Hz
    ui.drawLine(f125X, f125Y, f250X, f250Y);
    ui.drawCircle(f250X, f250Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[1] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(f125X, f125Y, f250X, y);
            ui.drawCircle(f250X, y, true);

            f250Y = y;

            equalizer.param(FREQUENCIES[1], dB);
        }
    }

    // 500 Hz
    ui.drawLine(f250X, f250Y, f500X, f500Y);
    ui.drawCircle(f500X, f500Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[2] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(f250X, f250Y, f500X, y);
            ui.drawCircle(f500X, y, true);

            f500Y = y;

            equalizer.param(FREQUENCIES[2], dB);
        }
    }

    // 1000 Hz
    ui.drawLine(f500X, f500Y, f1000X, f1000Y);
    ui.drawCircle(f1000X, f1000Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[3] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(f500X, f500Y, f1000X, y);
            ui.drawCircle(f1000X, y, true);

            f1000Y = y;

            equalizer.param(FREQUENCIES[3], dB);
        }
    }

    // 2000 Hz
    ui.drawLine(f1000X, f1000Y, f2000X, f2000Y);
    ui.drawCircle(f2000X, f2000Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[4] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(f1000X, f1000Y, f2000X, y);
            ui.drawCircle(f2000X, y, true);

            f2000Y = y;

            equalizer.param(FREQUENCIES[4], dB);
        }
    }

    // 4000 Hz
    ui.drawLine(f2000X, f2000Y, f4000X, f4000Y);
    ui.drawCircle(f4000X, f4000Y, false);

    if (context.isPointInPath(x, y)) {
        controllerCanvas.classList.add(className);

        ui.drawCross((FREQUENCIES[5] + ' Hz ' + Math.floor(dB) + ' dB'), x, y);

        if (isMouseDown) {
            ui.drawLine(f2000X, f2000Y, f4000X, y);
            ui.drawCircle(f4000X, y, true);

            f4000Y = y;

            equalizer.param(FREQUENCIES[5], dB);
        }
    }

    ui.drawLine(f4000X, f4000Y, controllerCanvas.width, middle);
};

const onMouseup = () => {
    isMouseDown = false;
};

controllerCanvas.addEventListener('mousedown', onMousedown, false);
controllerCanvas.addEventListener('mousemove', onMousemove, true);
controllerCanvas.addEventListener('mouseup', onMouseup, false);

少し長いコードになりますが, 実装していることは以下の繰り返しです.

  1. mousedown イベントでフラグを true にする
  2. mousemove イベントで UI を描画し, つまみの範囲にあれば, クロスを描画, さらに, マウスがダウン状態にあれば上下に動かすことが可能で, それによって, イコライザーの値を設定する
  3. mouseup イベントでフラグを false にする

駆け足になりましたが, 実装に関する概要は以上です.

FRESH! EQUALIZER の今後

今後もしばらくは FIL の日を利用して以下の 2 つに取り組んでいきます.

  • プリセット機能 (あらかじめ用意したイコライザーの値を設定する機能) の実装
  • SPA に関連するバグフィックス

将来的には, イコライザーに限らず, FIL の日を利用して, 立体音響の実現などもしていきたいと考えています.