Oscillator のカスタム波形を作った際の音量調整

# 波形から PeriodicWave を作る

Web Audio API において Oscillator でカスタム波形を作るには PeriodicWave を作って、それを割り当てる必要があります。
PeriodicWave を作るには倍音構成を表す 2 つの配列で渡す必要があるため、波形から作る際は離散フーリエ変換(DFT)を通さなければなりません。
例えば以下のようになります。ここでは DFT ではなく FFT を使用していますが、意味は同じです。

```js
// 波形(ここでは簡単にするために sine 波形)
const buffer = new Float32Array(2048);
for(let i=0, l=buffer.length; ++i) {
    buffer[i] = Math.sin( Math.PI * 2 * i / 2048 );
}

// 離散フーリエ変換で倍音構成に変換
const fdata = fft(buffer);

// PeriodicWave を作る
const ctx = new AudioContext();
const periodic = ctx.createPeriodicWave(...fdata);


// 離散フーリエ変換をする函数
function fft(input) {
    let n = input.length, theta = 2 * Math.PI / n,
        ar = new Float32Array(n), ai = new Float32Array(n),
        m, mh, i, j, k, irev,
        wr, wi, xr, xi,
        cos = Math.cos, sin = Math.sin;

    for(i=0; i<n; ++i) {
        ar[i] = input[i];
    }

    // scrambler
    i=0;
    for(j=1; j<n-1; ++j) {
        for(k = n>>1; k>(i ^= k); k>>=1);
        if(j<i) {
            xr = ar[j];
            xi = ai[j];
            ar[j] = ar[i];
            ai[j] = ai[i];
            ar[i] = xr;
            ai[i] = xi;
        }
    }
    for(mh=1; (m = mh << 1) <= n; mh=m) {
        irev = 0;
        for(i=0; i<n; i+=m) {
            wr = cos(theta * irev);
            wi = sin(theta * irev);
            for(k=n>>2; k > (irev ^= k); k>>=1);
            for(j=i; j<mh+i; ++j) {
                k = j + mh;
                xr = ar[j] - ar[k];
                xi = ai[j] - ai[k];
                ar[j] += ar[k];
                ai[j] += ai[k];
                ar[k] = wr * xr - wi * xi;
                ai[k] = wr * xi + wi * xr;
            }
        }
    }
    
    // remove DC offset
    ar[0] = ai[0] = 0;
        
    return [ar, ai];
}

// 3秒間再生する函数
function play() {
	const osc = ctx.createOscillator();
	osc.setPeriodicWave(periodic);
	osc.connect(ctx.destination);

	osc.start(ctx.currentTime);
	osc.stop(ctx.currentTime + 3.0);
}
```

(FFT のコードは http://www.kuma-de.com/blog/2008-08-31/82 を参考)

# 音量の問題

上記のように離散フーリエ変換を使い PeriodicWave を作って割り当てた Oscillator と、元の波形を SourceBuffer を使って再生した場合とでは音量が異なります。これは PeriodicWave の Wave Generation が

```math
x(t) = \sum_{k=1}^{L-1} \left(a[k]\cos2\pi k t + b[k]\sin2\pi k t\right)
```
(http://webaudio.github.io/web-audio-api/#waveform-generation より引用)

と定義されているのですが、この振幅のスケーリングと音量の関係性についての規定がないため、各ブラウザの実装依存だと考えられます。

またもう 1 つの要因として PeriodicWave はデフォルトで標準化をしており、これも音量に影響を与えます。まず逆離散フーリエ変換で元の波形の形にし、そしてその最大値で割ることで標準化しています。

```math
\tilde{x}(n) = \sum_{k=1}^{L-1} \left(a[k]\cos\frac{2\pi k n}{N} + b[k]\sin\frac{2\pi k n}{N}\right) \\\
f = \max_{n = 0, \ldots, N - 1} |\tilde{x}(n)| \\\
\hat{x}(n) = \frac{\tilde{x}(n)}{f}
```
(http://webaudio.github.io/web-audio-api/#waveform-normalization より引用)

よって Oscillator の音量を BufferSource に合わせる方法について考えます。

# 2つの解決方法

解決するには実験と検証を繰り返すしかありませんが、その結果以下の 2 つの方法がそれっぽい音量になるみたいです。

## disableNormalization フラグを使う場合

最新の Web Audio API の仕様では PeriodicWave の標準化をとめることができ、今のブラウザだと Google Chrome Canary で試すことが出来ます。

```js
ctx.createPeriodicWave(real, imag, {disableNormalization: true});
```

このとき離散フーリエ変換の正規化係数を以下のように 2/N にすると音量が合います。

```js:fft.js
// 離散フーリエ変換をする函数 (2/N倍している)
function fft(input) {
    let n = input.length, theta = 2 * Math.PI / n,
        ar = new Float32Array(n), ai = new Float32Array(n),
        m, mh, i, j, k, irev,
        wr, wi, xr, xi,
        cos = Math.cos, sin = Math.sin;

    for(i=0; i<n; ++i) {
        ar[i] = input[i];
    }

    // scrambler
    i=0;
    for(j=1; j<n-1; ++j) {
        for(k = n>>1; k>(i ^= k); k>>=1);
        if(j<i) {
            xr = ar[j];
            xi = ai[j];
            ar[j] = ar[i];
            ai[j] = ai[i];
            ar[i] = xr;
            ai[i] = xi;
        }
    }
    for(mh=1; (m = mh << 1) <= n; mh=m) {
        irev = 0;
        for(i=0; i<n; i+=m) {
            wr = cos(theta * irev);
            wi = sin(theta * irev);
            for(k=n>>2; k > (irev ^= k); k>>=1);
            for(j=i; j<mh+i; ++j) {
                k = j + mh;
                xr = ar[j] - ar[k];
                xi = ai[j] - ai[k];
                ar[j] += ar[k];
                ai[j] += ai[k];
                ar[k] = wr * xr - wi * xi;
                ai[k] = wr * xi + wi * xr;
            }
        }
    }
    
    // remove DC offset
    ar[0] = ai[0] = 0;
    
    // *** 追加 ***
    for(i=0; i<n; ++i) {
        ar[i] *= 2 / n;
        ai[i] *= 2 / n;
    }
    
    return [ar, ai];
}
```

## Gain を使う方法

離散フーリエ変換する前の波形の振幅の 2 倍の値を割り当てた Gain を Oscillator に繋げるやり方です。 
この方法だと disableNormalization をサポートしていないブラウザでも大丈夫です。

```js
// 波形の振幅を得る
let amplitude = 0;
for(let i=0, temp; i<buffer.length; ++i) {
	temp = Math.abs(buffer[i]);
	if(temp > amplitude)	amplitude = temp;
}

// 3秒間再生する函数
function Play() {
	const osc = ctx.createOscillator(), gainNode = ctx.createGain();
	osc.setPeriodicWave(periodic);
	osc.connect(gainNode);

	gainNode.gain.value = 2 * amplitude;
	gainNode.connect(ctx.destination);

	osc.start(ctx.currentTime);
	osc.stop(ctx.currentTime + 3.0);
}
```

## 2つの解決方法の等価性

Gain を使う方法を見てもらうと唐突に波形を 2 倍していると思われるのですが、この 2 つの解決方法は等価です。

元の波形の配列（数列）を A[n] と定義すると、Gain を使う方法で得た amplitude は以下のように定義されます。

```math
amplitude := \max_{n = 0, \ldots, N - 1} | \ A[n] \ | \ \cdots \! \cdots (1)
```

一方で disableNormalization フラグを使う方法において、離散フーリエ変換の正規化係数は 2/N で、音量の問題で指摘したように PeriodicWave の標準化で使われる逆離散フーリエ変換では正規化係数が 1 となっています。よって波形を離散フーリエ変換 F<sub>N</sub>、逆離散フーリエ変換 F<sub>N</sub><sup>-1</sup> に順に適用させると、以下のようになります。
ただし単純に波形を順に適用させた場合、波形に N をかけたことと恒等的に等しいことと、離散フーリエ変換、逆離散フーリエ変換は波形を低数倍した時、その定数を外にだせることを使っています。

```math
\begin{eqnarray}
F_N^{-1} ( \ F_N ( \ A[n] \ ) \ ) &=& N \cdot 1 \cdot \frac{2}{N} A[n] \\
                                  &=& 2 \ A[n]
\end{eqnarray}
```

従って、この時の波形の振幅 f は

```math
\begin{eqnarray}
f &=& \max_{n = 0, \ldots, N - 1} | \ 2 \ A[n] \ | \\
  &=& 2 \cdot \max_{n = 0, \ldots, N - 1} | \ A[n] \ | \ \cdots \! \cdots (2)
\end{eqnarray}
```

(1), (2) より

```math
f = 2 \cdot amplitude
```

よって Gain を使う方法は disableNormalization フラグを使う方法に対して、波形の振幅を合わせることで音量を同じにしていると言えるので、2 つの解決方法は等価です。

# 関連リンク

jsdo.it に書いた実験、検証用コード
http://jsdo.it/Moriken/sfDX/

Stack Overflow に投稿した質問
http://stackoverflow.com/questions/32402804/play-buffer-by-periodicwave-in-web-audio-api-can-i-set-gain-as-loud-as-buffer