技術的なやつ

技術的なやつ

6.1 すごいHaskellたのしく学ぼう! 第1章

カラオケ採点機制作の息抜きとして、「すごいH本」ことすごいHaskellたのしく学ぼう!を読み始めた。

すごいHaskellたのしく学ぼう!

すごいHaskellたのしく学ぼう!

Haskellとは

Haskellとは、純粋関数型言語である。CとかJavaとか普段我々がよく使う言語は手続き型言語と呼ばれる。

関数型言語では、「ある関数は必ず決まったある値を返す」ことを原則とする。そもそも、数学における関数の定義は「ある引数を与えるとある決まった値を返す」である。そのため、y=x^2において「yはxに関する関数」であるが、「xはyに関する関数」ではない*1。グラフを考えればわかるが、あるy(>0)を与えてもxの値は一意に定まらないのである。「ある引数を与えると必ず決まったある値を返す」関数のことを副作用のない関数と呼ぶ。Haskellは副作用のない関数しか扱えない。

手続き型言語では、関数は必ずしも値を返すわけではない(Cとか考えてもらうとわかる)。関数内にはそのメソッドが実行する手続きが書かれているのである。
ちなみに、Cだと簡単に副作用のある関数を書くことができる。たとえば、以下のような関数である。

int a;

int func(int x){
  return x + a;  // funcの値はグローバル変数aに依存する。すなわち、xに対して一意でない
}

また、Haskellでは遅延評価を基本とする。
遅延評価とは、「その値が必要となるまで計算しない」というものである*2。これによって、無限長のリストを扱うこともできる。なぜなら、本当に必要となるまでそのリストは評価されないからだ。

というわけで、Haskell手続き型言語とはまた異なった趣向の言語である。これがどういう世界で有効なのかというと、まぁ、今の私では数学への応用くらいしか思いつかない。要するに暇つぶしで読み始めた。

環境構築

Ubuntu12.04にて。

sudo apt-get install haskell-platform

これでHaskellコンパイラインタプリタ・基本ライブラリがインストールされる。
対話環境を起動するには、端末でghciと打つ。

okabi@pc:~/haskell$ ghci
GHCi, version 7.4.1: http://www.haskell.org/ghc/  :? for help
Loading package ghc-prim ... linking ... done.
Loading package integer-gmp ... linking ... done.
Loading package base ... linking ... done.
Prelude> 

試しに。

Prelude> 2 * 4
8

基本演算

Prelude> 1 + 2 * 3
7
Prelude> (1 + 2) * 3
9
Prelude> 5 / 2
2.5
Prelude> True && False
False
Prelude> True || False
True
Prelude> not False
True
Prelude> not (True && False)
True
Prelude> 5 == 5
True
Prelude> 5 /= 0
True
Prelude> "hello" == "hello"
True

Haskellは型が厳密なので、同じ型同士でないと比較はできない。

基本関数

関数succは与えた引数の次に位置する値を返す。

Prelude> succ 8
9
Prelude> succ 'a'
'b'

関数名を引数の前に置く関数を前置関数と呼ぶ。演算子'+'や'-'も、その前後にある数を引数とする関数と捉えることができる。このように引数の間に関数名を置く関数を中置関数と呼ぶ。

Prelude> min 7 9
7
Prelude> max 7 9
9
Prelude> div 5 2
2
Prelude> 5 `div` 2
2

2引数関数は中置関数っぽくすることも可能。その場合、関数名をバッククォート(Ctrl+@)で囲む必要がある。

関数定義

baby.hsというファイルを作って、以下のように書く。

doubleMe x = x + x

これは、引数1つ(x)を取り、x+xの値を返すdoubleMeという関数である。なお、関数名の最初の文字は小文字である必要がある。何かしら理由があるらしいがまだ知らない。
実際にghci上で使用するには、コードを保存した上でロードする。

Prelude> :l baby.hs
[1 of 1] Compiling Main             ( baby.hs, interpreted )
Ok, modules loaded: Main.
*Main> doubleMe 7
14

次に、「xとyを与えて2x+2yを返す」関数doubleUsを定義する。

doubleUs x y = doubleMe x + doubleMe y

評価の優先順位は演算子より関数のほうが高い。
次に、「xが100より大きい時はxを、それ以外の場合は2xを返す」関数doubleSmallNumberを定義する。

doubleSmallNumber x = if x > 100 then x else 2*x

Haskellでは、if文に必ずelseが必要である。なぜなら、未定義領域が出来るのでif文が「関数」とならないためである。

リスト

*Main> let lostNumbers = [4,8,15,16,23,42]
*Main> lostNumbers 
[4,8,15,16,23,42]

こんな感じで、'['と']'で囲むとリストになる。リストの要素はすべて同じ型でなければならない。つまり、

[1,'a']  # できない

みたいなことはできない。なお、GHCiでletを使うのは、定数宣言みたいなものだと思えば良い。
文字列は文字のリストである

*Main> let string = ['c','a','t']
*Main> string
"cat"

リストの結合には'++'を使う

*Main> let st1 = "a "
*Main> let st2 = "cat"
*Main> st1 ++ st2
"a cat"

リストの先頭に要素を追加するには':'を使う

*Main> let st = " cat"
*Main> 'a' : st
"a cat"

以下のような書き方はできない。

*Main> 'a' ++ " cat"  # できない。++はリストとリストをつなぐため。
*Main> "a " : "cat"  # できない。:は第1引数を要素で取らねばならないため。

リストのリストとかも書ける。

*Main> let a = [[1,2,3], [4,5,6], [7,8,9]]
*Main> a
[[1,2,3],[4,5,6],[7,8,9]]
*Main> a ++ [[10,11,12]]
[[1,2,3],[4,5,6],[7,8,9],[10,11,12]]
*Main> [-2,-1,0] : a
[[-2,-1,0],[1,2,3],[4,5,6],[7,8,9]]

リストの操作

*Main> let a = [9,8,7,6,5,4,3,2,1]
*Main> let b = [1,8,3,6,5,4,7,2,9]
*Main> a !! 2  # !! n はインデックスナンバーnの要素を取り出す。
7
*Main> a > b  # リストは先頭要素から順に比較ができる。
True
*Main> [9,8,7] < [9,9,1]  # 先頭要素が同じ場合、再帰的に次の要素の大小を見る。
True
*Main> [9,8,7] > [9,8,6,9]  # サイズが違っていても良い。あぶれた分は無視される(空リストとの比較という見方も可能)。
True
*Main> [1,2,3] > []  # 空リストは必ず最小。
True
*Main> head a  # aの先頭要素を返す。
9
*Main> tail a  # aの第2要素以降のリストを返す。
[8,7,6,5,4,3,2,1]
*Main> last a  # aの最終要素を返す。
1
*Main> init a  # aの最終要素を除いたリストを返す。
[9,8,7,6,5,4,3,2]
*Main> length a  # aのサイズを返す。
9
*Main> null a  # aが空リストならTrueを返す。
False
*Main> null []
True
*Main> reverse a  # aを反転させたリストを返す。
[1,2,3,4,5,6,7,8,9]
*Main> take 3 a  # aの第n要素までをリストにして返す。
[9,8,7]
*Main> take 1 a
[9]
*Main> take 0 a
[]
*Main> drop 3 a  # aの第n要素までを落としたリストを返す。
[6,5,4,3,2,1]
*Main> drop 0 a
[9,8,7,6,5,4,3,2,1]
*Main> drop 100 a
[]
*Main> maximum a  # aの最大要素を返す。
9
*Main> minimum a  # aの最小要素を返す。
1
*Main> sum a  # aの総和を返す。
45
*Main> product a  # aの積を返す。
362880
*Main> 6 `elem` a  # aに要素が存在する場合はTrueを返す。
True
*Main> 0 `elem` a
False

レンジ

範囲を指定して、それをすべて要素とするリストを作ることができる。これをレンジと呼ぶ。
また、最初の要素を指定することで等差数列のリストを作ることもできる。これをステップと呼ぶ。

*Main> [1..9]
[1,2,3,4,5,6,7,8,9]
*Main> [1,3..9]
[1,3,5,7,9]
*Main> [1,4..20]
[1,4,7,10,13,16,19]

レンジを利用して無限リストを作ることもできる。たとえば、3から始まる3の倍数のうち24番目までのリストを得るには、以下のようにすれば良い。

*Main> take 24 [3,6..]
[3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72]

これは、Haskellが遅延評価だからこそできる芸当である。つまり、無限リスト生成時には実際にはリスト内容が計算されていないのである。take 24を適用させることで、初めて無限リスト(の一部)が評価される。
その他、特筆すべきリスト利用について。

*Main> take 10 (cycle [1,2,3])  # cycleは与えたリストを繰り返す無限リストを生成する。どこかで切らないと無限ループするので注意。
[1,2,3,1,2,3,1,2,3,1]
*Main> take 12 (cycle "LOL ")
"LOL LOL LOL "
*Main> take 10 (repeat 5)  # repeatは与えた要素を繰り返す無限リストを生成する。
[5,5,5,5,5,5,5,5,5,5]
*Main> replicate 10 5  # replicateはx回yを繰り返すリストを生成する。
[5,5,5,5,5,5,5,5,5,5]
*Main> [0.1,0.3..1]  # 実数は浮動小数点数なので、期待した値とは異なるものが得られる場合があるので注意
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

リストの内包表記

Haskellの大きな特徴の一つだと思う。
Haskellでは、リストを数学の集合表記と似たような表記法で記述可能である。
たとえば、\{2x | x \in N, x \lt 10\}のような集合表記があったとする(10未満の自然数xについて2xを要素とする集合)。これは、Haskellでは以下のように表記する。

*Main> [2*x | x <- [1..9]]
[2,4,6,8,10,12,14,16,18]

50から100のうち、7で割ったあまりが3であるすべての数を含むリストは以下のように定義する。

*Main> [x | x <- [50..100], x `mod` 7 == 3]
[52,59,66,73,80,87,94]

また、要素表記に複数のリストを使った場合は、すべての組み合わせが要素となる。

*Main> [x+y | x <- [1,2,3], y <- [10,100,1000]]
[11,101,1001,12,102,1002,13,103,1003]

ここで、リストの長さを返すlength'を自分で定義してみよう*3

length' xs = sum [1 | _ <- xs]

ちょっと分かりにくいので説明。
引数として与えたリストxsから、順番にアンダーバーに要素を取っていく(事実的に要素を1つずつ捨てている)。取るたびに、1を要素としてリストに追加する。つまり、xsのサイズの、1のみを要素としたリストができる。その総和を取ることで、xsの長さを得る。

タプル

タプルとは、リストのようなものである。リストとの違いは以下の通り。

  • 異なる型を1つのタプルに纏められる
  • タプル比較の際、長さが等しくすべてのインデックスの型が等しい必要がある

長さ2のタプルはペア、長さ3のタプルはトリプルと呼ばれる。
以下、タプルを扱う基本関数と共に使用例。

*Main> (1,'a') < (1,'b')
True
*Main> fst (1, 2)  # fstはペアの最初の要素を返す。
1
*Main> snd (1, 2)  # sndはペアの最後の要素を返す。
2
*Main> zip [1, 2, 3, 4, 5] "abcde"  # zipは2つのリストからペアのリストを作る。
[(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')]
*Main> zip [1..5] "abc"  # リストの長さが異なる場合、短い方に合わせられる。
[(1,'a'),(2,'b'),(3,'c')]
*Main> zip [1..] ['a'..'f']  # よって、無限リストにも対応可能。
[(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e'),(6,'f')]

感想

1回生の頃に触っていたSchemeLispの方言)と似たような感じだと思った(特にリストの扱い方とか)。まぁ、Lispも一応は関数型言語に属するので、当然といえば当然である。
数学の集合記法でリストが表現できる点が面白い。また、Schemeと同じく遅延評価を持つ言語なので、無限リストを扱えるという点も面白い。

*1:すなわち、逆関数が存在しないということ。

*2:私が知る限りでは、Lispにもそのような機構が存在する。尤も、Lispは破壊的代入が可能であるという点で副作用のある関数を書けてしまうので、純粋関数型言語ではない。

*3:Haskellでは「'」に特別な意味はないので、普通に関数名に使うことができる。

5.6 要素の調整と融合

  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. なめらかさ判定アルゴリズムを考える

f:id:ibako31:20140214002026j:plain

特に新しいことはやっていない。今まで作ってきた要素を調整し、融合して1つのものとした。

譜面の調整

バックに音程の基準を表す土台をつけた。左端に音程も示している。黒鍵音は黒っぽいバックにしていたりと何気に凝っている。

譜面はGUITextureを使って表示していたが、そもそもGUI系は画面上に表示するゲージとかに使われるもので、最も上に描画されてしまう。今回の場合、それは非常に困るので、新しいGameObject(Plane)を生成してテクスチャを貼り付ける作戦に出た。
譜面をめくる度に、以下の処理を行う。

  1. ノートを全て消去する(GameObjectをDestroyする)
  2. 現在の譜面で必要なノートを計算する
  3. 新たなGameObjectを生成する

しかし、この方法だと譜面をめくる度に0.3秒ほどの処理落ちが発生した。どうやらGameObjectの生成・削除は少し重い処理らしい。

よって、以下の方法に変更した。

  1. 初期化時にノートを全て計算し、GameObjectを生成する
  2. 譜面をめくる度に、全てのノートを非表示にする(SetActive(false))。また、現在の譜面で必要なノートを計算し表示を有効にする

これでもやや処理落ちが発生するものの、全然マシだったので採用。

あと苦労したのは、ノートの表示位置の基準。以下のアルゴリズムで最高点の音程を決定している。

  1. 表示している譜面の平均音程を中央に持ってくるように最高点の音程を決定する
  2. 表示している譜面の最高音程が表示できていない場合、最高点の音程をその音程とする
  3. 2以外の場合で表示している譜面の最低音程が表示できていない場合、最低点の音程をその音程とする

…だけど、どうやらどこかにバグが有るらしく、たまに上手く動かない(妙に上に表示される)。まぁ、ぼちぼちと修正していこう。

演奏位置の表示

赤線のこと。
Unityには再生中のオーディオの再生位置を手に入れる機能が標準で搭載されている。

public AudioSource obj;

void Start(){
    obj.Play();
}

void Update(){
    Debug.Log(obj.time);  // 再生位置がfloat型・秒数で取得できる
}

なお、数値を弄って再びPlayすることで指定位置から再生も可能。
これと全ての要素を同期させている。

歌唱線の表示

音が取れていない場所でも線が繋がるのは不格好なので、いくつかLineRendererを用意して…と考えていたが、非常に面倒なのでパスした。まぁ、機能上そこまで影響ないやろ…。
ノートがない場所では譜面中に収まるオクターブに、ある場所ではノートとの差が最も小さくなるオクターブに合わせて表示する。JOYでよく発生するオクターブエラーはこれで回避できる。

16:9表示への対応

カメラサイズを適当にしていたので、全画面表示で左右が切れてしまった。
今の時代だと16:9のモニターが標準だと思うので、それに合わせて作りなおした。


f:id:ibako31:20140214002009j:plain

カエルの歌を歌った。

5.5 MIDIの解析

  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. なめらかさ判定アルゴリズムを考える

やりたいことをやってくれるライブラリが見つからなかったので、今回はとても苦労しました。

Standard MIDI Format

MIDIは、音楽ファイルの形式の一つである。
.wavや.mp3は音波データそのものを記録しているため、再現性が高い代わりにデータが大きく編集が困難である。対照的に、.midは楽譜データを記録しているため、再現性が低い(音源依存)代わりにデータが小さく編集が容易である。
そんなMIDIの標準的な規格が、Standart MIDI Format(SMF)である。
SMFは、ヘッダ部分とトラックデータ部分に分かれている。

たとえば、以下のようなMIDIファイルを考える。これはDominoという有名なエディタである。
f:id:ibako31:20140209004213j:plain
見えていないが、第1トラックでテンポを120に設定している。また、見えている第2トラックでは四分音符でド、四分音符でレ、二分音符でミを鳴らし、一小節で終わる。
これをフォーマット1(後述)でSMF(Standard MIDI File)に変換し、バイナリエディタで開いてみる。
f:id:ibako31:20140209004406j:plain
今回は、このデータを解析していく。このデータは、ヘッダ+トラックごとのデータで構成されている。

ヘッダ

f:id:ibako31:20140209004617j:plain

4D 54 68 64 00 00 00 06 ** ** ** ** ** **
"MThd" 実データの大きさ(必ず6) フォーマット(0000/0001/0002) トラック数 時間単位

ヘッダは以上の14bytesからなる。

フォーマット

フォーマット0は、全てのトラック(パート)を1トラックに押し込めたデータである。プレイヤーが譜面を起こすのが楽になる反面、編集データからの不可逆的な変換のため編集が困難になる。
フォーマット1は、トラックごとに譜面データを記録している。こちらは編集データから可逆的な変換である。
フォーマット2は、まず使われることがないらしいので省略。

トラック数

パートの数。

時間単位

正の数(先頭ビットが0)の場合、デルタタイム(後述)において、四分音符1個分を表す数値。分解能のことだと思えば良い。
負の数(先頭ビットが1)の場合は違う意味を表すが、まぁ、省略。

これを基に、画像のデータを解析してみよう。

  • フォーマット … 00 01 -> 1
  • トラック数 … 00 02 -> 2
  • 時間単位 … 01 E0 -> 480

実データ

ヘッダの後に、トラックごとのデータが続く。たとえば、トラック2は以下の部分である*1
f:id:ibako31:20140209005651j:plain
それぞれのトラックについて、以下の様な構造になっている。

4D 54 72 6B ** ** ** ** ** ** ~
"MTrk" 実データのデータ長 実データが続く

すなわち、"MTrk"で始まった後4bytesで表されたデータ長があり、実データが続く。

実データは以下の様な構造になっている。ここがややこしい部分である。

** ~ ** **~
デルタタイム ステータスバイト 諸々
デルタタイム

以降続く命令を実行するまでに待機する時間。RoboLabを触ったことがある人は、アレのウェイトと同じようなものだと考えれば良い。ヘッダで設定した時間単位を基準として記述する。
ところで、デルタタイムは何byteあれば十分なのだろうか。SMFでは、デルタタイムは4bytesまで設定可能となっている。ところが、大抵の命令のデルタタイムはもっと少ないbyteで収まることは容易に想像できると思う。
そこで、SMFでは可変長形式でデルタタイムが記述されている。これがめんどくさい。

可変長形式

理屈は単純で、先頭bitが1の時は続くbyteもデルタタイムを表し、0の時はそこがデルタタイム表現の終点であるだけである。これを実際のbit列に変換するには、先頭ビットを取っ払って右に詰めれば良い。
たとえば、以下の様になっている。接続判断bitを赤色で示す。また、見やすいように2進数表記は1byteずつ縦線で区切った。

16進数 2進数 可変長2進数 可変長16進数
40 0100 0000 0100 0000 40
7F 0111 1111 0111 1111 7F
80 1000 0000 1000 0001 | 0000 0000 8100
200000 0010 0000 | 0000 0000 | 0000 0000 1000 0001 | 1000 0000 | 1000 0000 | 0000 0000 81 80 80 00

要点は、今見ているアドレスをindexとすると先頭ビットが1かどうか判断するには以下のようなコードを書けば良いということである。

if((index & (byte)0x80) > 0){
    // byte型のindexは先頭ビットが1
}
ステータスバイト

1byteで、命令の種類を表す。以下に、カラオケ採点上必要な項目のみ述べる。nは0~Fまでの数値で、対象チャンネルを表す。また、ノートオン/オフの場合、ステータスバイトの後の1byteは対象音階を表し、その後の1byteはベロシティ(強さ)を表す。

** 意味
8n ノートオフ
9n ノートオン
(A~E)n ほげほげ
F0またはF7 ふがふが*2
FF メタイベント(後述)
ランニングステータス

上で述べた通り、ステータスバイトは「(8~F)* **」の形式であり、すなわち先頭ビットは必ず1である。ここで、ステータスバイトの先頭ビットが1でない場合、前回のステータスバイトを受け継ぐ(すなわち、ステータスバイトを省略する)というデータ圧縮が可能である。これをランニングステータスと呼ぶ*3

メタイベント

色々とオプション的なイベントである。カラオケ採点においてはテンポ変更とトラック終端くらいしか重要なものがないので、そこを中心に述べる。
メタイベントは以下の様な形式である。

ステータスバイト メタイベント種類 データ長(可変長形式) データ 意味
FF 51 03 ** ** ** テンポ変更
FF 2F 00 ---- トラック終端

メタイベントは必ず上のような形式になっているため、カラオケ採点の場合は読み込み時、テンポ変更以外のメタイベントに当たった場合、データ長を把握してその分スキップする処理が重要である。

テンポ変更について。
データは3bytesである。この数値は四分音符のマイクロ秒を表す。つまり、BPM(1分あたりの四分音符の数)をBとすると、60 \cdot 10^6 / Bとなる。


以上を把握した上で、先ほどのトラック2の実データ部分(54byte目から)を読んでみよう。
f:id:ibako31:20140209005651j:plain
分かりやすくするため、デルタタイムは赤色、ステータスバイトは黄色、メタイベントは水色で表す。

イベント 意味
00 FF 03 00 デルタタイム0後にメタイベント03を実行、データ長は0(つまり、無い)
00 FF 21 01 00 デルタタイム0後にメタイベント21を実行、データ長は1でデータは0
00 90 30 64 デルタタイム0後にチャンネル0の音階30をベロシティ64でON
83 60 80 30 00 デルタタイム83 60(可変長形式)後にチャンネル0の音階30をOFF
00 90 32 64 デルタタイム0後にチャンネル0の音階32をベロシティ64でON
83 60 80 32 00 デルタタイム83 60(可変長形式)後にチャンネル0の音階32をOFF
00 90 34 64 デルタタイム0後にチャンネル0の音階34をベロシティ64でON
87 40 80 34 00 デルタタイム87 40(可変長形式)後にチャンネル0の音階34をOFF
00 FF 2F 00 トラック終端

つまり、音階30はド、音階32はレ、音階34はミということである。

フォーマット1のデータ読み込み

フォーマット1の場合、トラックごとに変数を作り、並列的に読んでいくしか無い。
現在の時間から最も短い時間でイベントにたどり着けるのがどのトラックか把握し、それを再帰的に処理すれば良い。

カラオケ採点のために

カラオケ採点のためには、「どのようなイベントが」「どのくらいの時間(秒)」継続するか知ることが大切である。
そこで、イベントを「何もしない」「ノートhogeをON」「テンポ変更」「トラック終端」の4種類に分けてそれに継続時間を付属させた。
以下、SMFLibraryのソースである。投げやりな感じで貼り付けるが、上の説明をよく読めば理解できるはずだし、私も相当苦労したのでこれで勘弁して欲しい。ソース中にはコメントを大量に付けたので、それを読めばある程度理解できるはずである。

using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace SMFLibrary
{
    // Standard Midi Format のカラオケ用読み込みに特化したライブラリ
    public class StandardMidiFormat
    {
        // イベントリスト用
        public class Event
        {
            // 0:何もしない 1:BPM変更 2:ノートON -1:EOF
            public int type;

            // イベント内容。タイプに応じて意味が違う
            // 1:BPM 2:ONにするノート 0,-1:未定義
            // ノートはA0(55Hz)が0になり、半音上がるごとに1上がる
            public int value;

            // イベントの継続時間(ms)
            public int time;
        }

        
        // 現在読み込んでいるファイルの名前
        public string filename { get; private set; }

        // 現在読み込んでいるMIDIのイベントリスト
        // ただし、BPM変更とノートON/OFF(1音)のみ
        public List<Event> event_list;

        // 4分音符に当たるデルタタイム
        private int time_unit;

        // テンポ(四分音符に当たるms)
        private int tempo;


        // byte列からshort型整数に変換する
        private int ToInt16(byte[] data, int index)
        {
            byte[] ar = new byte[2];
            ar[0] = data[index];
            ar[1] = data[index + 1];
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(ar);
            }
            return BitConverter.ToInt16(ar, 0);
        }

        
        // byte列からint型整数に変換する
        private int ToInt32(byte[] data, int index)
        {
            byte[] ar = new byte[4];
            ar[0] = data[index];
            ar[1] = data[index + 1];
            ar[2] = data[index + 2];
            ar[3] = data[index + 3];
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(ar);
            }
            return BitConverter.ToInt32(ar, 0);
        }


        // bit列からint型整数に変換する
        private int ToInt32FromBits(byte[] data, int index)
        {
            int value = 0;
            for (int i = 0; i < 32; i++)
            {
                value += data[index + 31 - i] * (int)Math.Pow(2, i);
            }
            return value;
        }


        // バイナリファイルを読み込み、配列で返す
        private byte[] _load_binary(string filename)
        {
            System.IO.FileStream fs = new System.IO.FileStream(@filename, System.IO.FileMode.Open, System.IO.FileAccess.Read);
            int filesize = (int)fs.Length;
            byte[] data = new byte[filesize];
            fs.Read(data, 0, filesize);
            fs.Dispose();

            return data;
        }


        // dataのindexから始まる可変長形式の数値をint型に変換する
        // plus_posは、可変長形式の数値がそこから何byteあったか
        private int _convert_variable_to_int(byte[] data, int index, out int plus_pos)
        {
            // 可変長形式の最大byte
            int max_variable_size = 5;

            // 変換後の最大byte
            int max_int_size = 4;
            
            // データサイズ
            int size = 1;
            for(int i = 0; (data[index+i] & (byte)0x80) > 0; i++)
            {
                size++;
            }
            if (size > max_variable_size)
            {
                Debug.LogError("variable size is bigger than 5");
                plus_pos = 0;
                return 0;
            }
            
            // 変換後の各ビットデータ
            byte[] bits = new byte[max_int_size*8];
            
            // 埋めるべきビット
            int pos = bits.Length - 1;
            
            // ビットデータを埋める
            for (int i = 0; i < size; i++)
            {
                for(int j = 0; j < 7; j++)
                {
                    byte comp = (byte)(1 << j);
                    if((data[index+size-1-i] & comp) > 0)  bits[pos] = 1;
                    pos--;
                }
            }

            // 埋まったビットデータからintに変換して返す
            plus_pos = size;
            return ToInt32FromBits(bits, 0);
        }


        // デルタタイムを実際の時間(ms)に変換する
        private int _convert_delta_time_to_ms(int delta_time)
        {
            return (int)((float)tempo * (float)delta_time / (float)time_unit);
        }

        // data内の第nトラックがindex[n-1]から始まるとして、最も早く実行される命令を返す
        // before_commandはトラックの直前ステータスバイト、timeはトラックの現在時間
        // indexとtimeの中身は変更される。indexとtimeが-1になったら、そのトラックは終了した
        // どのトラックも終了していたらnullを返す
        private Event _get_command(byte[] data, int[] index, byte[] before_command, int[] time)
        {
            // 現在の全体の時間
            int world_time = time.Max();

            // 最小の(world_timeからの)デルタタイム・最小デルタタイムのインデックスナンバー・動かすインデックス座標
            int min_delta_time = -1;
            int min_index = -1;
            int min_plus_pos = -1;
            for (int i = 0; i < index.Length; i++)
            {
                if (index[i] >= 0)
                {
                    int plus_pos;
                    int delta_time = time[i] + _convert_variable_to_int(data, index[i], out plus_pos) - world_time;
                    if (min_delta_time < 0 || delta_time < min_delta_time)
                    {
                        min_delta_time = delta_time;
                        min_index = i;
                        min_plus_pos = plus_pos;
                    }
                }
            }

            // 次に実行されるトラックが分かったから、その内容を読んでindexと時間を動かす
            if (min_index >= 0)
            {
                index[min_index] += min_plus_pos;
                time[min_index] += min_delta_time;

                // ステータスバイト
                byte status_byte = data[index[min_index]];
                Event ev = new Event();

                // ステータスバイトの最上位ビットが1なら、具体的命令
                if ((status_byte & (byte)0x80) > 0)
                {
                    index[min_index]++;
                    before_command[min_index] = status_byte;
                }
                // ステータスバイトの最上位ビットが0なら、前回の命令
                else
                {
                    status_byte = before_command[min_index];
                }
                
                byte st = (byte)(status_byte & (byte)0xF0);
                if (st == (byte)0x80) ev.type = 0;  // ノートオフ
                else if (st == (byte)0x90) ev.type = 2;  // ノートオン
                else if (status_byte == (byte)0xFF && data[index[min_index]] == (byte)0x51) ev.type = 1;  // テンポ変更
                else if (status_byte == (byte)0xFF && data[index[min_index]] == (byte)0x2F) ev.type = -1;  // トラック終端
                else  // 無効命令
                {
                    if (st == (byte)0xA0 || st == (byte)0xE0)
                    {
                        index[min_index] += 2;
                    }
                    else if (st == (byte)0xC0 || st == (byte)0xD0)
                    {
                        index[min_index] += 1;
                    }
                    else if (st == (byte)0xB0)
                    {
                        if (data[index[min_index]] == (byte)0x7E && data[index[min_index] + 2] == (byte)0x04)
                        {
                            index[min_index] += 3;
                        }
                        else
                        {
                            index[min_index] += 2;
                        }
                    }
                    else
                    {
                        int plus_pos;
                        int length = _convert_variable_to_int(data, index[min_index] + 1, out plus_pos);
                        index[min_index] += 1 + plus_pos + length;
                    }
                    return _get_command(data, index, before_command, time);
                }

                // ノートオフかオンかテンポ変更の命令だけ抽出したので、諸々の処理
                // 次のデルタタイムが記録されているインデックスナンバー
                int next_delta_time_index = index[min_index];  
                
                // 何もしない処理
                if (ev.type == 0)
                {
                    next_delta_time_index += 2;
                    index[min_index] += 2;
                }
                // BPM変更の処理
                else if (ev.type == 1)
                {
                    next_delta_time_index += 5;
                    ev.value = 0;
                    ev.value += (int)((uint)data[index[min_index] + 2] * 65536);
                    ev.value += (int)(uint)(data[index[min_index] + 3] * 256);
                    ev.value += (int)(uint)(data[index[min_index] + 3]);
                    ev.value /= 1000;
                    tempo = ev.value;
                    index[min_index] += 5;
                }
                // ノートオンの処理
                else if (ev.type == 2)
                {
                    next_delta_time_index += 2;
                    ev.value = (int)data[index[min_index]];
                    index[min_index] += 2;
                }
                // トラック終端の処理
                else if (ev.type == -1)
                {
                    index[min_index] = -1;
                    time[min_index] = -1;
                }
                int pp;
                // デルタタイムを実際の時間(ms)に変換して代入
                ev.time = _convert_delta_time_to_ms( _convert_variable_to_int(data, next_delta_time_index, out pp) );

                return ev;
            }
            return null;
        }


        // ファイルを読み込み、イベントリストを得る
        public void Load(string filename)
        {
            byte[] data = _load_binary(filename);
            event_list = new List<Event>();
            tempo = 500;  // BPM120

            // ヘッダを読み込む
            if (data.Length < 4)
            {
                Debug.LogError("File Length is not suitable.");
            }
            else if (!(data[0] == (byte)'M' && data[1] == (byte)'T' && data[2] == (byte)'h' && data[3] == (byte)'d'))
            {
                Debug.LogError("File is not Standard MIDI Format.");
            }
            else if (data[9] == 2)
            {
                Debug.LogError("Format 2 is not suitable.");
            }
            else
            {
                // このMIDIのフォーマット
                int format = ToInt16(data, 8);

                // このMIDIのトラック数
                int track_num = ToInt16(data, 10);

                // 4分音符に当たるデルタタイム
                time_unit = ToInt16(data, 12);

                if (format == 2)
                {
                    Debug.LogError("format is 2.");
                }
                else if (time_unit < 0)
                {
                    Debug.LogError("time_unit is minus.");
                }
                else
                {
                    // 読み込んでいるインデックス(トラックごと)
                    int[] pos = new int[track_num];

                    pos[0] = 22;
                    for (int i = 1; i < track_num; i++)
                    {
                        pos[i] = pos[i - 1] + ToInt32(data, pos[i - 1] - 4) + 8;
                    }

                    // 各トラックの現在の時間
                    int[] time = new int[track_num];

                    // 各トラックの直前命令
                    byte[] before_command = new byte[track_num];

                    // 「何もしない」をイベントリストの先頭に追加
                    int min_time = -1;
                    for (int i = 0; i < track_num; i++)
                    {
                        int plus_pos;
                        int t = _convert_variable_to_int(data, pos[i], out plus_pos);
                        if (min_time < 0 || t < min_time) min_time = t;
                    }
                    Event first_ev = new Event();
                    first_ev.type = 0;
                    first_ev.time = min_time;
                    event_list.Add(first_ev);

                    // 各トラックのうち、現在の時間+デルタタイムが最も小さいものはどれか?
                    // その命令を解析する
                    while (pos[0] != -1 || (pos.Length >= 2 && pos[1] != -1))
                    {
                        Event ev = _get_command(data, pos, before_command, time);
                        event_list.Add(ev);
                    }

                    // メンバの変更
                    this.filename = filename;

                }            
            }
        }
    }
}

これを使って、以下のようなMIDIファイルを解析し、Unity上で表示した。Un-Demystifed Fantasy の主旋律である。
f:id:ibako31:20140209015339j:plain
f:id:ibako31:20140209015448j:plain
なんか左に詰めて表示されるなぁ…。

フォーマット0にして、もう一度やってみた。
f:id:ibako31:20140209015635j:plain

今度は上手く行った。フォーマット0推奨にしたほうが良いかもしれない。まぁ、必要なのは主旋律データだけだし、フォーマット0でも問題ないか…。


バイナリデータ弄ったり、信号変換したりの作業は楽しかったが、実に非人間的であった。二度とやりたくない。

*1:トラック1は短いが、様々な特殊命令が複合されていてややこしいのでトラック2を例に挙げた

*2:本当はデータ長的に無視できないが、そのような命令を入れないということを前提にしたい(不精してるだけ)

*3:Dominoで出力したSMFにはランニングステータスは適用されていないようである

5.4 マイク入力の音程解析

  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. なめらかさ判定アルゴリズムを考える

注文したマイクが届いた。
f:id:ibako31:20140206223250j:plain
ちなみに、コレである。ケーズデンキの通販で買って2400円位だった。安い。
正しい用途は動画投稿サイトへの歌の投稿らしい。

SONY エレクトレットコンデンサーマイクロホン PCV80U ECM-PCV80U

SONY エレクトレットコンデンサーマイクロホン PCV80U ECM-PCV80U

サウンドライブラリ

マイク入力については少し置いておき、先日までに書いた音程解析メソッドを整理してライブラリとする。

using UnityEngine;
using System.Collections;

public static class SoundLibrary{

    // オーディオの周波数を調べる
    // ac: 解析したいオーディオソース
    // qSamples: 解析結果のサイズ
    // threshold: ピッチの閾値
    public static float AnalyzeSound(AudioSource ac, int qSamples, float threshold)
    {
        float[] spectrum = new float[qSamples];
        ac.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);
        }

        float pitchValue = freqN * (AudioSettings.outputSampleRate / 2) / qSamples;
        return pitchValue;
    }

    // ヘルツから音階への変換
    public static float ConvertHertzToScale(float hertz)
    {
        float value = 0.0f;
        if (hertz == 0.0f) return value;
        else
        {
            value = 12.0f * Mathf.Log(hertz / 110.0f) / Mathf.Log(2.0f);
            // while (value <= 12.0f) value += 12.0f;
            // while (value > 36.0f) value -= 12.0f;
            return value;
        }
    }

    // 数値音階から文字音階への変換
    public static 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;
    }

    // 数値音階から生波形を出す
    public static void ScaleWave(float[] scale, int size, LineRenderer line)
    {
        line.SetVertexCount(size);
        float x = -150.0f;
        for (int i = 0; i < size; i++)
        {
            line.SetPosition(i, new Vector3(x, -80.0f + scale[i] * 2.5f, 0));
            x += 1.0f;
        }
    }
}

マイク入力の音程解析

簡単に言うと、以下の様な仕組みである。

  1. AudioSourceのclip(流すファイル)とマイク入力を関連付ける
  2. そのオーディオを再生させる(録音音声を聞きたくない場合はミュートにする)
  3. 前回の記事の要領で音程解析を行う

重要なのは以下のメソッド。

AudioClip Microphone.Start(string deviceName, bool loop, int lengthSec, int frecency)

deviceName

録音に使うマイクの名前。

loop

録音時間が尽きたら最初から録音を再開するか

lengthSec

録音時間

frecency

サンプリング周波数

以下が音程解析ソース。以前までの記事に詳しいことは書いているので、特に説明は必要ないと思う。

using UnityEngine;
using System.Collections;

public class MicroFFT : MonoBehaviour {
    // 波形を描画する
    public LineRenderer line;

    // マイクからの音を拾う
    public AudioSource mic;
    private string mic_name = "UAB-80";

    // 波形描画のための変数
    private float[] wave;
    private int wave_num;
    private int wave_count;


    void Start () {
        // 波形描画のための変数の初期化
        wave_num = 300;
        wave = new float[wave_num];
        wave_count = 0;
        
        // micにマイクを割り当てる
        mic.clip = Microphone.Start(mic_name, true, 999, 44100);
        if (mic.clip == null)
        {
            Debug.LogError("Microphone.Start");
        }
        mic.loop = true;
        mic.mute = true;

        // 録音の準備が出来るまで待つ
        while (!(Microphone.GetPosition(mic_name) > 0)) { }
        mic.Play();
    }
	
    void Update () {
        // 諸々の解析
        float hertz = SoundLibrary.AnalyzeSound(mic, 1024, 0.04f);
        float scale = SoundLibrary.ConvertHertzToScale(hertz);
        string s = SoundLibrary.ConvertScaleToString(scale);
        Debug.Log(hertz + "Hz, Scale:" + scale + ", " + s);
        
        // 波形描画
        wave[wave_count] = scale;
        SoundLibrary.ScaleWave(wave, wave_count, line);
        wave_count++;
        if (wave_count >= wave_num) wave_count = 0;
    }
}

これで、適当にマイクに向かって発声してみました。
f:id:ibako31:20140206224458j:plain
なかなか綺麗に出ます。さすが単一指向マイクです。
ちなみに、私の声域(マイクが拾ってくれた音)は約98Hz~630Hzでした。およそ2オクターブ半なので、まぁ妥当なところだと思います。
AudioSourceをもう一つ追加し、BGMを流しながらちょっとだけ歌いました。
f:id:ibako31:20140206225203j:plain
CROSSOっぽくなってきました。

課題

  • 最初のテストでは2秒の遅延が生じた(以降はほぼ遅延なし)。どうやら、録音準備が出来て実際に録音がスタートしてから、mic.Play()が有効化するのに時間がかかると遅延が発生するらしい。これは回避手段があるのだろうか…。あるとすれば、録音中の時間と再生時間を合わせるくらいか(出来るのか)。
  • 「あ」から「ん」まで発音したが、さ行とた行辺りでノイズが強く発生した。また、「る」でも大きく波がブレた。もしかして、「る」の特殊歌唱ってあまり良くなかったのでは…

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:確かめていないのでちょっと怪しいが、たぶんそうだろう

5.2 Unityでオーディオファイルを利用する

  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. なめらかさ判定アルゴリズムを考える

UnityでMIDI再生は出来ない!

早速ですが、UnityでMIDI再生は出来ません。└(┐┘)┌ゴミだァ~~~~~
色々と探しましたが、お金を払わない範囲ではどうしようもないようです。

新しい設計

というわけで、カラオケ採点機のための新しい設計を考えました。

  • オーディオファイル(wavとか)を鳴らす
    • 主旋律やテンポなどのデータを取ったファイルをMIDIとして用意、読み込んでwavファイルと同期させる

この設計にすることで、以下の様な利点と欠点が生まれます。

利点
  • 原曲音源でもカラオケが出来る
  • MIDIの解析が楽になる
  • 既存のUnity機能を使って楽に制作できる
欠点
  • キー変更が出来ない
  • テンポ変更が出来ない
  • テンポを完コピしたMIDIを用意しなければ同期ズレが発生する

特にキー変更が出来ないのは痛いですが、仕方がないです。

Unityでオーディオファイルを扱う

MIDIの解析が山場になることが確定しましたが、とりあえずUnityでオーディオファイルを扱う方法についてメモ。
f:id:ibako31:20140202172524j:plain
空のGameObjectを作成し、Add Component -> Audio -> Audio Source。上のようなものがInspectorに出てきます。
音源を対象プロジェクトのAssetsフォルダに追加します。Unityに戻ると自動で同期されます。オーディオファイルの場合、同期に時間がかかる場合もあるみたいです。
f:id:ibako31:20140202173034j:plain
こんな感じにインポート出来るはずです(画像のオーディオファイルは作業時に聞いていたものです)。
先ほど作成したAudio SourceのAudio Clipにドラッグしておきます。

さて、楽しいコーディングです。
Add ComponentからC#スクリプトを加えます。名前はAudioにでもしておきましょう。
以下の様なコードを書きましょう。

using UnityEngine;
using System.Collections;

public class Audio : MonoBehaviour {
	public int start_time;

	// Use this for initialization
	void Start () {
		audio.Play();
		audio.time = start_time;
	}
	
	// Update is called once per frame
	void Update () {
		Debug.Log(audio.time);
	}
}

audio.Play()

このGameObjectに割り当てられたAudio Sourceを再生する。

audio.time

このGameObjectに割り当てられたAudio Sourceの再生位置を設定および読み込みする。

フレームごとに再生時間を出力します。
start_timeを弄ってから再生することで、開始時間を変更できます。
f:id:ibako31:20140202174738j:plain
音質がものすごく劣化するけど、回避方法はあるのかな。

参考文献

OSC, MIDI プラグイン (Unity) – Keijiro Takahashi
オーディオファイルの再生と動機して、MIDIデータから譜面を読み込み何だかんだするライブラリ(SmfLite)があります。ここから新設計の着想を得ました。

keijiro_smflite at test · GitHub.htm
SmfLiteのサンプルです。ここからUnityのAudioについて知見を得ました。

5.1 Unityで波形を表示する

というわけで、もう一つのブログに書いてあった通り、カラオケ採点機を作っていく。
春合宿での発表のためにも、こまめにノート(記事)を取っていくことにした。
記事内で扱う内容は赤文字、既に終わった内容は灰文字で表示する*1

  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. なめらかさ判定アルゴリズムを考える

Unity

たった一日しか触っていないので、かなり適当言ってる可能性があることをご了承ください。

Unityとは、最近流行っているゲーム製作エンジンである。公式サイトはここ
このエンジンの注目すべき特徴は以下の通り。

この中で、特にマルチプラットフォームであることが注目を浴びている。つまり、Unity上であるゲームを完成させれば、それをWindows用にもMac用にもLinux用にもAndroid用にもiOS用にもビルドできるということである。家庭用ゲーム用にもビルド出来るらしい。凄いですね。
使用するために、公式サイトから適当にファイルを落としてインストールしましょう。なんか登録が必要だった気がするのでメールアドレスを用意しましょう。有料版もありますが、無料版で十分だと思うので無料版をインストール。

以下、記事内容はWindows7上のUnity4.3.3f1を想定しています。

Unityでのゲーム製作

インストールできたら起動しましょう。
普通は3Dに飛びつきますが、今回は2Dで事足りるので2Dを選択してNewProjectをCreateしましょう。
f:id:ibako31:20140201231321j:plain
Projectフォルダを設定し、左下のやつを2Dに変えて、Createを押します。チェックボックスは特に何もつけなくて良いです。
f:id:ibako31:20140201231829j:plain
こんな感じの画面が出ると思います。なんか余計なウィンドウが出るかもしれないですが、消してください。
順番に説明しましょう。

Scene

左上のSceneっていう枠のこと。
Unityでは、Project(=ゲーム)はSceneの集まりとして扱う。つまり、爆幻をProjectとして例にすると、Sceneはタイトル画面・実際のゲーム画面、という感じになる。

Game

Sceneの下にあるGameという枠のこと。
実際のゲーム画面を表す。

Hierarchy

真ん中あたりのHierarchyという枠のこと。
現在のSceneに存在するGameObjectの一覧がある。GameObjectはUnityでのゲーム構成要素の末端要素のようなもの。爆幻でいうと、プレイヤーキャラであったりタイトルロゴであったりする。

Projects

Hierarchyの横にあるProjectsという枠のこと。

Favorites

私も何か知らない┐(└┌)┘

Assets

全Scene共通で使えるパーツのこと。画像素材であったり音楽素材であったり共通ソースであったりを突っ込むらしい。

Inspector

一番右にあるInspectorという枠のこと。
GameObjectをクリックすると、ここにその詳細(Components)が出てくる。

初めはMain CameraというGameObjectが存在します。クリックしてみると、以下の様な画面が出ると思います。
f:id:ibako31:20140201232948j:plain
この画面で、InspectorにはMain CameraのComponentsが表示されています。Transform・Camera・GUILayer・Flare Layer・Audio Listenerですね。それぞれに細かい要素があります。たとえば、Transformを見ると、Position・Rotation・Scaleですね。それぞれ、メインカメラの座標・回転・拡縮を表します。これらは、この画面で数値を変更することも可能ですし、C#/JavaScript/Booで書いたスクリプトによって制御することも可能です。今回はC#を使います。

まとめると、こんな感じです。

  • Project(ゲームそのもの)
    • Scene(ゲームの一場面)
      • GameObject(シーンの構成要素。キャラとかロゴとか)
        • Component(オブジェクトの構成要素。座標とか)

Hello World!

上のメニューからGameObject -> Create Empty。
f:id:ibako31:20140201233831j:plain
こんな感じで、空のGameObjectが出来ます。F2か右クリック -> RenameかInspectorから「Hello」にリネームしておきましょう。
Inspectorから「Add Component」を選びます。New Script -> LanguageをC SharpにしてCreate and add。
ここから、別エディタを使ってC#のコードを書いていきます。なんかよく分からんエディタがデフォルトになってるので、適当に使い慣れたエディタに設定変更しましょう。
上のメニューから、Edit -> Preferences -> External Tools -> External Script Editorを好きなものに変更しましょう。私はMicrosoft Visual Studioにしました。
エディタの設定が終わったら、AssetsにあるC#のコードをダブルクリック(または、クリックからInspectorのopen)。VS立ち上がりOSEEEEEE!!!!
f:id:ibako31:20140201234551j:plain
…とりあえず、こんな感じのコードが出るはずです。

using UnityEngine;
using System.Collections;

public class NewBehaviourScript : MonoBehaviour {

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
	
	}
}

詳しいことは置いといて、メソッドの説明。と言っても、コード内にちゃんと英語で書いてあるのですが…。

void Start()

ゲームの起動時に一度だけ実行される内容。initみたいなもんですね。

void Update()

フレームごとに実行される内容。

というわけで、HelloWorldを書きます。

void Start () {
        Debug.Log("Hello World!");
}

書いたら保存して、Unityに戻ります。
f:id:ibako31:20140201235244j:plain
Inspectorの中身も更新されればOKです。上のメニューから、Window -> Consoleでコンソールウィンドウを出します。
f:id:ibako31:20140201235416j:plain
Clearでコンソールウィンドウの中身をクリアできます。しておきましょう。
Hierarchyの上の方にある再生/一時停止/1フレームごとに再生を表すボタンのうち、再生を押してみましょう。実行にはやや時間がかかります。
f:id:ibako31:20140201235751j:plain
こんな感じになればOK。

正弦波を描画する

まず、カメラを引いておきます。
HierarchyのMainCameraをクリックし、Sceneの上辺りにある十字のマーク(左から2番め)をクリック。Sceneの上でマウスホイールを下に回すと、カメラが引いていきます。
初め、グリッドは10刻みで表示されていますが、引きまくると100刻みになります。とりあえずx座標が-200~200まで入るようにしましょう。その後、真ん中の白い長方形の辺上にある点をドラッグし、カメラ範囲をx座標が-200~200まで入るようにします。
f:id:ibako31:20140202000440j:plain
GameObject -> Create Emptyで新しいGameObjectを作ります。名前はsinにでもしておきましょう。
コンポーネントとして、Effects -> LineRendererを追加します。
LineRendererは、指定した複数座標を直線で繋ぎ、描画します。今回だと正弦波の描画を担当します。
作ったコンポーネントのParametersを開き、Start ColorとEnd Colorに適当な色を設定します。
また、MaterialsのElement0をSprites Defaultにしておきましょう。横のラジオボタンみたいなのを押せばOK。
f:id:ibako31:20140202002547j:plain
こんな感じにしましょう。

コンポーネントとして、再びC#スクリプトを追加します。スクリプト名はsinにでもしておきましょう。
以下のようにコードを書いてください。

using UnityEngine;
using System.Collections;

public class sin : MonoBehaviour {
	public LineRenderer lr;
	private int theta;
	private int count;

	// Use this for initialization
	void Start () {
                theta = 0;
                count = 0;
                lr.SetVertexCount(count);
	}
	
	// Update is called once per frame
	void Update () {
                if(count > 400) count = 0;
                count++;
                lr.SetVertexCount(count);
                lr.SetPosition(count - 1, new Vector3(-200 + count, 80 * Mathf.Sin(theta * Mathf.PI / 180), 0));
                theta++;
	}
}

説明が必要そうなものだけ、順番に説明しましょう。

public LineRenderer lr;

publicにするとソース中で操るコンポーネントを指定することが出来ます。
このコードを保存した後、InspectorのSinを見てください。
f:id:ibako31:20140202003252j:plain
こんな感じで、lrにコンポーネントを指定することが出来ます。
この上に、先ほど作ったLineRendererのコンポーネントをドラッグしてみましょう。すると、それが割り当てられるはずです。
privateだと、この項目は出てきません。

private int theta;

描く正弦波のラジアンを表します。

private int count;

実行からの時間を表します。ただし、400フレーム周期です。

lr.SetVertexCount(int n);

LineRendererの頂点数を指定します。

lr.SetPosition(int index, Vector3 pos)

LineRendererの第index頂点の座標を設定します。

さて、再生してみましょう。以下の様な正弦波がループしていれば成功です。
f:id:ibako31:20140202003910j:plain

*1:C#の復習についてはパスします(時間ないので)