SiONでハナウタ機能(音高検出)を作る

=前置き=

 バンブラ耳コピや作曲をやっていると「原曲を聴くorメロディを思いつく→ハナウタでその音を出す→ハナウタ機能で実際の音(音高)を見つける」という流れで作るのがラクなんだけど、バンブラPではハナウタ機能がなくなってしまったのでできなくなった。Android音高を検出するだけのアプリもあるものの、自分の作曲ツールにもこの機能が欲しかったのでこれを作って組み込んでみた。というわけでそこらへんのメモ。

 ちなみに作曲ツール自体はそれなりに形になってきたらなんらかの形で公開する予定。

=ライブラリ=

 作曲ツールはAdobe Airで作っており、音を出すのにSiONを使わせてもらっている。で、直接音高をとる機能はないものの、音高をとるのに必要なフーリエ変換をサポートしている(org.si.utils.FFT)ので、ここではこれを使う。


アルゴリズム

 ハナウタから音高をとるには、音声データをもとに「一番大きい音を出している周波数」を求めれば良い。音声データそのものは周波数が合成されたものなのでそのままでは「一番大きい音を出している周波数」はとれないけれど、音声データをフーリエ変換すると周波数ごとに分離できるため「一番大きい音を出している周波数」を求めることができる。

 ということで、ハナウタから音高をとる手順は以下のようになる。

  • マイクから音声データを取得する
  • 音声データをフーリエ変換する
  • 変換後の周波数データから一番大きいものを選ぶ
  • 一番大きい部分をHzとして計算する
  • 求めたHzに対応する音高(音名)を求める

 特にフーリエ変換自体については説明しないけど、基礎的な理解としてはフーリエ変換の本質が役に立つかと思う。

 Hzから音高(音名)を計算する部分は以下のような感じ。

  • ラ=440 Hzとする
  • 1オクターブ上がると2倍の880 Hzとなる
  • 1オクターブ下がると半分の220 Hzとなる
  • ラから次のラまでは12段階(12半音)あるので、1段階につき「2の12乗根」倍になっている
  • 実際の音(Hz)がラ(440 Hz)の何倍になっているかを求め、それが「2の12乗根」の何回分になっているか求める

 実際には今回の用途では音名さえわかれば十分なので、220 Hz〜440 Hzに収まるように事前に計算してから「220 Hzの何倍になっているか」→「対数をとって何回分か」を調べて求める。


=コード=

 実際のコードはだいたい以下のような感じ。

 ただ、論理的には正しくない場所が2箇所あるので注意。

 1つ目は音の大きさを「getIntensity」で取得している部分。Intensityは「音の大きさ(実部)と位相のズレの大きさ(虚部)」の両方を合成したものであり、位相のズレの方は本来は不要。ただ、あれこれ試したところ、Intensityの方がキレイに目的の音高を取得しやすかったのでこの方式を採用している。

 2つ目はHzを求めている部分。mic.rateの単位はkHzなので実際には1000倍すべきだったりあれこれするが、わりと複雑なのでとりあえず合致するようにやや強引に計算している。ここはマイクから与えられるデータが増減したりSOURCE_LENを増減させたりすると正しくなくなるかもしれない。が、手元での検証が難しいので正確なところは保留にしている。(ハナウタ機能は自分の端末で正常に動けばひとまず十分なので、ひとまずここまでで置いておいて他の機能の作成に入りたかった)

import flash.events.*;
import flash.media.*;
import flash.utils.*;

import org.si.utils.FFT;

class Hanauta
{
	//==Const==

	//Param
	//- データの大きさ
	//- マイクのサンプリングデータの大きさ(float:4byte×SOURCE_LENがByteArray.lengthと一致するようにしておく)
	static public const SOURCE_LEN:int = 1024;


	//==Var==

	//FFT
	//- フーリエ変換を行う本体
	public var fft:FFT;
	//- 音声データ
	public var src:Vector.<Number>;
	//- 変換後のデータの実部(音量に相当)
	public var dst:Vector.<Number>;

	//Mic
	//- マイク
	public var mic:Microphone;


	//==Function==

	//Constructor
	public function Hanauta(){
		//FFT
		Init_FFT();

		//Mic
		Init_Mic();
	}

	//Init : FFT
	public function Init_FFT():void{
		fft = new FFT(SOURCE_LEN);
		src = new Vector.<Number>(SOURCE_LEN);
		dst = new Vector.<Number>(SOURCE_LEN/2);
	}

	//Init : Mic
	public function Init_Mic():void{
		mic = Microphone.getMicrophone();
		if(mic != null){
			//サンプリングレート
			//- 低いほうが分解能は高くなるらしい(Hzの計算時に影響)
			//mic.rate = 44;//44,100 Hz
			//mic.rate = 8;//8,000 Hz
			mic.rate = 5;//5,512Hz
			//mic.rate = 1;//test:ムリっぽい

			//どれくらいの音量から取得するか
			//- 低いと環境音にも反応してしまうので用途に応じて要調整
			mic.setSilenceLevel(4);

			//マイクのデータを受け取るコールバックの設定
			mic.addEventListener("sampleData", OnSampleData);
		}
	}

	//Callback : Mic
	public function OnSampleData(e:SampleDataEvent):void {
		var buf_num:int = e.data.bytesAvailable / 4;//float:4byteの格納数

		//マイクのデータをフーリエ変換に回す
		for(e.data.position = 0; 0 < e.data.bytesAvailable; ) {
			Exec_FFT(e.data);

			break;//1回だけで終わらせてみる(音高とるだけならそれで十分なはず)
		}

		//音程を求める
		Calc_Note(buf_num);
	}

	//Exec : FFT
	public function Exec_FFT(data:ByteArray) : void {
		for(var i:int = 0; i < SOURCE_LEN; ++i){
			src[i] = data.readFloat();
		}
		fft.setData(src).calcFFT();//高速フーリエ変換
	}

	//Calc : Note
	public function Calc_Note(buf_num:int):void{
		//dstに変換後のデータを入れる
		//- 実部ではなくIntensityを使ってるのは、こちらの方がキレイに音高を取れることが多かったため
		fft.getIntensity(dst);

		//一番大きい部分を取得
		var max:Number = 10;//ノイズな反応を除去するため、ここでも閾値を設定してみる
		var index:int = -1;
		for(var i:int = 0; i < dst.length; ++i){
			if(max < dst[i]){
				max = dst[i];
				index = i;
			}
		}

		//閾値を超える部分がなければムシ
		if(index < 0){
			return;
		}

		//Hzを求める
		//-   サンプリング時の周波数
		//- * フーリエ変換での周波数
		//- * 実際のサンプリングデータ数/フーリエ変換に渡したデータ数
		//- = 本来の周波数、のはず(ここはあまり自信ない)
		var mic_rate:Number = mic.rate;
		//- rate=5の時は厳密には5.512でだいぶ違うので補正(他はそこまで大きくは違わない)
		//-- 参考:http://help.adobe.com/ja_JP/FlashPlatform/reference/actionscript/3/flash/media/Microphone.html#rate
		if(mic_rate == 5){mic_rate = 5.512;}
		var hz:Number = mic_rate * index * (buf_num/SOURCE_LEN);

		//ラ=440 Hz として音名計算
		//- 880 Hz, 220 Hzがそれぞれ1オクターブ上下となる
		var key_name:String = "";
		{
			const KEY_NAME_ARR:Vector.<String> = new <String>[
				"A",
				"B♭",
				"B",
				"C",
				"D♭",
				"D",
				"E♭",
				"E",
				"F",
				"G♭",
				"G",
				"A♭",
			];

			//Hzを操作して音名を検出
			var base_hz:Number = hz;
			if(base_hz <= 10){base_hz = 220;}//低い値の精度はどうせ高くないので、適当に値を設定してしまう(エラー対策)
			//まずは220Hz〜440Hzに収めてみる
			while(base_hz < 220){base_hz *= 2;}
			while(440 <= base_hz){base_hz /= 2;}
			//これでbase_hz/220は1〜2の係数となり、Log2をとることで平均律としてのratioが求まる
			//Log2(base_hz / 220) = Log(base_hz / 220) / Log(2)
			var ratio:Number = Math.log(base_hz / 220) / Math.log(2);
			//1/12ごとに半音上がるものとして捉えることができるので、12段階にすることで最終的な音名を求める
			var key_index:int = int(ratio*12 + 0.5) % 12;

			key_name = KEY_NAME_ARR[key_index];
		}

		//あとはkey_nameを表示すればOK
		//- Hzも併記するとわかりやすい
		//- さらにdstをグラフ化して表示すると波形データとしてもわかりやすい
	}
}

=まとめ=

 というわけでフーリエ変換でハナウタから音高を取得する機能を作った。コードのところで書いたように他の端末で動くかは怪しい部分があるので、ツールを公開する時はもうちょっとここら辺の検証を済ませてからにしたい。