技術的なやつ

技術的なやつ

5.3 FFTとオーディオの音程解析

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

マイクがまだ届いていないので、とりあえずオーディオファイルの音程を解析する部分を作った。

音程解析とは

オーディオファイルの音程を解析するには、以下の様な手順を踏む。

  1. ある部分の波形をFFT高速フーリエ変換)にかけ、周波数の分布を得る
  2. ピッチ(最も強く出ている音)を確定させる
  3. 周辺の分布も加味しつつ、ピッチの音程を確定させる

ノイズの影響を受けやすく、めっちゃ難しい。

FFT高速フーリエ変換

FFT(Fast Fourier Transform)とは、離散フーリエ変換の高速アルゴリズムである。具体的にはO(n^2)がO(nlogn)になる。これがどれほど凄いことかというと、たとえばn=1000のときを考えて見れば良い。前者は100万になり、後者は約1万になる*1。計算時間が100分の1になっている。リアルタイムに離散フーリエ変換を行う際、FFTはほぼ必須となってくる。
フーリエ変換とは、まぁ簡単に言うとx軸が時間・y軸が音圧だったものをx軸が周波数・y軸が分布に変えることである。つまり、オーディオファイルのある時間帯にどのくらいの周波数の音がどのくらい存在しているかが分かる。
詳しくはWikipediaに投げる。

Unityのココが凄い

Unityには、初めからオーディオファイルの高速フーリエ変換を行う関数が存在しています。

audio.GetSpectrumData(float[] samples, int channel, FFTWindow window);

これで、チャンネルchannelの*2オーディオファイルのFFT結果をsamplesに入れることが出来ます。samplesのサイズは2の乗数である必要があります。まぁ、1024を使うのが無難っぽい?windowは窓関数というものらしく、なんだかよく分かりませんがノイズを軽減する効果があるみたいです。だいたいFFTWindow.BlackmanHarrisを入れておけばいいと思います。
これで得られるデータは、Nをsamplesのインデックスナンバー・FをAudioSettings.outputSampleRateの値・Qをsamplesのサイズとすると、\frac{NF}{2Q}で与えられる周波数(Hz)です。
ピッチを探すには、samplesの中で最も大きな値を持っているものを探せば良いです。これがおおよそピッチの周波数になるのですが、配列の各要素は飛び飛びの周波数の分布を表しているので、周辺の分布にもピッチが影響している可能性があります。
そこで、なんだかよく分かりませんが魔法の数式を使います。ピッチとして検出したインデックスナンバーをNとし、samples[N](S_{N}と略記します)に\frac{1}{2}((\frac{S_{N+1}}{S_{N}})^2-(\frac{S_{N-1}}{S_{N}})^2)を足します。この式、本気で意味がわからないですが、確かに誤差が抑えられる効果を持っています。
ちなみに参考にしたのはここです。これのほぼ丸パクリになりますが、音程を解析するAnalyzeSound関数を定義します。

private int qSamples = 1024; //配列のサイズ
private float threshold = 0.04f; //ピッチとして検出する最小の分布
private float pitchValue; //ピッチの周波数

private float[] spectrum; //FFTされたデータ
private float fSample; //サンプリング周波数

void AnalyzeSound()
{
	audio.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
	float maxV = 0;
	int maxN = 0;
	//最大値(ピッチ)を見つける。ただし、閾値は超えている必要がある
	for (int i = 0; i < qSamples; i++)
	{
		if (spectrum[i] > maxV && spectrum[i] > threshold)
		{
			maxV = spectrum[i];
			maxN = i;
		}
	}

	float freqN = maxN;
	if (maxN > 0 && maxN < qSamples - 1)
	{
		//隣のスペクトルも考慮する
		float dL = spectrum[maxN - 1] / spectrum[maxN];
		float dR = spectrum[maxN + 1] / spectrum[maxN];
		freqN += 0.5f * (dR * dR - dL * dL);
	}
	pitchValue = freqN * (fSample / 2) / qSamples;
}

これを使って、440Hzの正弦波の分布を見ると以下のようになります。
f:id:ibako31:20140206024431j:plain
多少誤差はありますが、まぁまぁ正確な周波数が出ています。

周波数から数値音階への変換

さて、ここからはカラオケ採点として数値を楽に扱うために、周波数から数値音階への変換方法を考えます。
数値音階というのは私の造語です。ここでは以下のように定義します。

数値音階とは、以下の性質を満たす実数である。

  • 110Hzを0とする
  • ある数値音階から1大きい数値音階は、半音上の音階を表す
  • ある数値音階から1小さい数値音階は、半音下の音階を表す

これは何をするためのものかというと、CROSSOのような歌唱線を作るためのものです。
ところで、音階と周波数に関する基礎知識を整理しましょう。

  • 440HzはA(ラ)の音
  • 周波数が2倍になるとちょうど1オクターブ高い音となる

以降、ドレミファソラシドをCDEFGABと表します。上の基礎知識は、つまり110Hz・220Hz・440Hz・880Hz…はAの音ということです。これをそれぞれA1・A2・A3・A4…と定義します。
A1からA2の間には、嬰音(#の付いた音)も考えると12音存在します。この12音は、110Hzから220Hzの間に均等に配置されています。
つまり、x=0をA1・x=1をA#1・x=2をB1…として、yを対応する周波数とすると、y=110\cdot{2}^{\frac{x}{12}}という関係式が出来ます。これを整理すると、x=\frac{12log\frac{y}{110}}{log2}となります。ここで、logは自然対数です。

また、数値音階を素に最も近い音階を文字列で表示できるようにすると便利でしょう。今回は四半音まで判断できるようにしました(たとえば、A1とA#1の間の音はA1+と表します)。

以下がコードになります。

// ヘルツから音階への変換
float ConvertHertzToScale(float hertz)
{
	if (hertz == 0) return 0.0f;
	else return (12.0f * Mathf.Log(hertz / 110.0f) / Mathf.Log(2.0f));
}

// 数値音階から文字音階への変換
string ConvertScaleToString(float scale)
{
	// 12音階の何倍の精度で音階を見るか
	int precision = 2;

	// 今の場合だと、mod24が0ならA、1ならAとA#の間、2ならA#…
	int s = (int)scale;
	if (scale - s >= 0.5) s += 1; // 四捨五入
	s *= precision;

	int smod = s % (12 * precision); // 音階
	int soct = s / (12 * precision); // オクターブ

	string value; // 返す値

	if (smod == 0) value = "A";
	else if (smod == 1) value = "A+";
	else if (smod == 2) value = "A#";
	else if (smod == 3) value = "A#+";
	else if (smod == 4) value = "B";
	else if (smod == 5) value = "B+";
	else if (smod == 6) value = "C";
	else if (smod == 7) value = "C+";
	else if (smod == 8) value = "C#";
	else if (smod == 9) value = "C#+";
	else if (smod == 10) value = "D";
	else if (smod == 11) value = "D+";
	else if (smod == 12) value = "D#";
	else if (smod == 13) value = "D#+";
	else if (smod == 14) value = "E";
	else if (smod == 15) value = "E+";
	else if (smod == 16) value = "F";
	else if (smod == 17) value = "F+";
	else if (smod == 18) value = "F#";
	else if (smod == 19) value = "F#+";
	else if (smod == 20) value = "G";
	else if (smod == 21) value = "G+";
	else if (smod == 22) value = "G#";
	else value = "G#+";
	value += soct + 1;

	return value;
}

さらに、CROSSOみたいに波形が出るような機構を作りました。これについてのコードは省略します。
これを用いて、C2からC3までの正弦波を流しました。
f:id:ibako31:20140206030608j:plain
いい感じだったので、試しに普通の楽曲を流してみました。これは初音ミクの激唱です。
f:id:ibako31:20140206031045j:plain
う~ん、なかなか絶望的な感じ。なお、閾値以上の音が取れていない場合は最下点に持って行っています。
とにかくBGM(特にドラム)が妨害してくる感じなので、ボーカルのみの曲を探しました。
これなんか、解析できないと話にならなさそうですね。やってみました。
f:id:ibako31:20140206031705j:plain
まぁまぁという感じでしょうか。ここまで綺麗なデータでもノイズの影響は出るみたいです。音程はそこそこちゃんと取れていますが、何故かオクターブ違いで取ってしまっている箇所がありますね。JOYSOUNDのアレって普通に起こり得る現象なんですね。

課題

  • ノイズによって波形が乱れる現象について考える

*1:logの底は2で2^10が1024だから、log1000は約10と考える。

*2:確かめていないのでちょっと怪しいが、たぶんそうだろう