波形から PeriodicWave を作る
Web Audio API において Oscillator でカスタム波形を作るには PeriodicWave を作って、それを割り当てる必要があります。
PeriodicWave を作るには倍音構成を表す 2 つの配列で渡す必要があるため、波形から作る際は離散フーリエ変換(DFT)を通さなければなりません。
例えば以下のようになります。ここでは DFT ではなく FFT を使用していますが、意味は同じです。
// 波形(ここでは簡単にするために 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 が
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 はデフォルトで標準化をしており、これも音量に影響を与えます。まず逆離散フーリエ変換で元の波形の形にし、そしてその最大値で割ることで標準化しています。
\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 で試すことが出来ます。
ctx.createPeriodicWave(real, imag, {disableNormalization: true});
このとき離散フーリエ変換の正規化係数を以下のように 2/N にすると音量が合います。
// 離散フーリエ変換をする函数 (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 をサポートしていないブラウザでも大丈夫です。
// 波形の振幅を得る
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 は以下のように定義されます。
amplitude := \max_{n = 0, \ldots, N - 1} | \ A[n] \ | \ \cdots \! \cdots (1)
一方で disableNormalization フラグを使う方法において、離散フーリエ変換の正規化係数は 2/N で、音量の問題で指摘したように PeriodicWave の標準化で使われる逆離散フーリエ変換では正規化係数が 1 となっています。よって波形を離散フーリエ変換 FN、逆離散フーリエ変換 FN-1 に順に適用させると、以下のようになります。
ただし単純に波形を順に適用させた場合、波形に N をかけたことと恒等的に等しいことと、離散フーリエ変換、逆離散フーリエ変換は波形を低数倍した時、その定数を外にだせることを使っています。
\begin{eqnarray}
F_N^{-1} ( \ F_N ( \ A[n] \ ) \ ) &=& N \cdot 1 \cdot \frac{2}{N} A[n] \\
&=& 2 \ A[n]
\end{eqnarray}
従って、この時の波形の振幅 f は
\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) より
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