原文: Onset Detection Part 1: The Basics

この記事では少しずつシリーズで音楽ゲームを作るのに必要な音の立ち上がり検出器を作る方法について書いていきたいと思う。まずは基本から始める。さまざまな音声ファイルの読み込み方については、すでにインターネット上にたくさんの解説があるのでここでは触れない。ここに書くのは私がどうやって音の立ち上がり検出器を作ったかである。まずは FRAPS で撮影したさまざまなジャンルの音楽の音の立ち上がり検出を 90% の精度で行うこのビデオを観てほしい。

King Crimson – Three of a perfect pair

Some Mozart

Audioslave - Cochise

上記のビデオは詳細を確かめるために HD モードを観てほしい。また、FRAPS がいくつものタイミングを逃したので、タイムロスが多数存在する。このビデオを観れば、3 つの周波数サブバンドについて 3 つの検知関数を利用していることが分かる。どういうことかはあとで触れる。今わかってほしいのは、緑色の線の上の赤い頂点が、それがドラムであろうともギターであろうともバイオリオンであろうとも、本質的に音の立ち上がりとなっているということだ。このシステムは自動的に音楽のジャンルに適合することができ、発見することができたもっとも代表的なリズムの音の立ち上がりを選別する。20 ほどのさまざまな曲でこの検出スキーマを試してみたが、すべてとてもうまく行った。さらに、このスキーマは実装が非常に簡単である。ともあれ基本から始めることにしよう。

音は空気や水などの媒体に存在する波と考えることができる。音を録音するというのは、マイクに対する媒体の圧力、もっと精確に言えば波の振幅を記録することだ。デジタル録音はさらに 1 ステップ進んでこの記録のプロセスを離散化する必要がある。マイクは非常に高い頻度 (frequency) でチェックされ、頻度が高ければ高いほど、録音の品質はよくなる。この頻度はサンプリングレートとも呼ばれている。標準的な CD の音は 44100Hz (ヘルツ) のサンプリングレートを持っている。これが意味するのは、1 秒に 44100 回マイクへの振幅が計測されたということだ。ここでいう「計測」とはいわゆるサンプリングのことで、サンプリングを行うたびにいわゆるサンプルを得ることができる。サンプルの値はどうにかして保存しておかなければならない。ここでいわゆるビット深度やエンコーディングが登場する。ビット深度は、サンプルの振幅値を表すのに何ビット使われているかを表す。これは 1 バイトのこともあるし、short (2 バイト) や word (4 バイト) のこともある。上記のほかは普通は使われない。もちろん振幅値のとりうる範囲を定義しておく必要がある。符号つき (signed) か 符号なし (unsigned) ということになるが、たいていは符号つきが利用される。サンプルは整数や正規化浮動小数点数の値として保存することもできる。整数のサンプルはそのサンプルに利用されたバイト数に応じて範囲が決まる。つまり 16 ビットで符号つきのサンプルは -32768 から 32767 までの範囲の値となる。浮動小数点のサンプルはほぼいつでも -1 から 1 の範囲で正規化され、整数のサンプルよりも少しだけ扱いやすくなっている。16 ビット整数のサンプルと 32 ビット浮動小数点数のサンプルはかけ算と割り算で簡単に変換することができる (やり方は自分で考えてみてほしい :))。

純粋な PCM データ (あるビット深度、あるエンコード、例えば整数、浮動小数点数でサンプリングされたサンプル) に必要なストレージ容量を把握するために、モノラル (つまり PCM チャンネルが 1 つしかない) でサンプリングレートが 44100Hz の曲について考えてみよう。サンプルは 16 ビットの符号つき整数で保存される。曲の演奏時間は全体で 180 秒である。サンプリングレートによれば、楽曲での 1 秒ごとに 44100 個のサンプルを取得することになる。それぞれのサンプルには 16 ビットつまり 2 バイトが必要だ。よって音楽 1 秒につき、88200 バイト、つまりだいたい 88Kb が必要となる。曲の長さは 180 秒だから、88100*180 = 15876000 バイト、つまりだいたい 15MB が必要となる。なんとこんなしょうもない曲がこんなにも大きなデータになるとは。32ビットの浮動小数点数でサンプルを保存するなら、必要なメモリ量は 2 倍、つまり 30MB となる。

こういうことがあるから、よく使われているフォーマットでは PCM データに対してなんらかの圧縮をほどこし、扱いやすく転送可能なサイズまで小さくしている。MP3 や Ogg Vorbis のようなフォーマットはこのような圧縮を非常にうまく行っているが、その代償としてオリジナルの品質はいくらか失われている。このようなフォーマットで利用されている音の圧縮アルゴリズムは不可逆であり、つまりは復元を行ってもオリジナルのデータを 100% 取り戻すことができない。このような圧縮アルゴリズムは、失われる部分がほとんどのリスナーには聴こえない部分になるように設計されている。

音楽を分析したいときには、圧縮されたフォーマットでは役に立たない。どうにかしてストレートな PCM データを得なければならない。そのためにデコーダーが存在する。Java, C, C# にはその種のライブラリが非常にたくさん存在するので、自分で実装を行う必要はない。ここでは特定のデコーダーについて詳しく説明は行わない、インターネット上にたくさんの解説が存在するのでそちらを参照してほしい。MP3 なら C には libmadlibmpg123 があるし、Java には JLayer がある。Ogg Vorbis なら C には libvorbis があり、Java には Jorbis がある。どれでも好きなものを選んで、ライブラリに付属のドキュメントやサンプルコードを読んでみるとよい。このようなライブラリのほとんどは非常に使うのが簡単である。

デコーダーから取得した PCM データがメモリ上に存在するとしよう。ほとんどの曲はステレオだと思うので、最初にすべきなのは左の各サンプルとこれに対応する右のサンプルの平均値をとり、2 つのチャンネルをマージすることだ。PCM データは十中八九 16 ビットの符号つき整数なので、浮動小数点数の配列への変換も行っておく。ここで実装する関数の戻り値は n 個の連続した PCM サンプルを保持する浮動小数点数の配列となるだろう。コードはこんな感じだ。

public float[] convertPCM( short[] leftChannel, short[] rightChannel )
{
   float[] result = new float[leftChannel.length];
   for( int i = 0; i < leftChannel.length; i++ )
      result[i] = (leftChannel[i] + rightChannel[i]) / 2 / 32768.0f;
   return result;
}

2 つの short 型の配列 (1 つは左チャンネルのサンプルを保持し、もう 1 つは右チャンネルのサンプルを保持している) を受け取り、2 つのチャンネルの平均をとり、さらに平均値を 32768.0f で割って浮動小数点数で [-1,1] の範囲の振幅値を取得している。 (技術的には符号つき short 型の範囲に従って、負数のサンプルは 32768 で割り、正数のサンプルは 32767 で割る必要があるのだが、面倒なのでやらない。)

ここまでやったら次は何ができるだろうか? まずは特定の時点、たとえば曲が開始して 10 秒時点でのサンプルをどうやって取得するか見てみよう。これを行うにはサンプリングレートを知る必要がある。この情報は幸いにもデコーダーライブラリが教えてくれるだろうが、だいたいの場合、ほとんどすべての MP3 や Ogg のサンプリングレートは 44100Hz だ。ここから先ではサンプリングレートが 44100Hz であると仮定する。サンプリングレートというのは 1 秒に何個のサンプルが取得されたかを示すものだというのを思い出してほしい。44100Hz ではそれぞれのサンプルが 44100 分の 1 秒、つまり 0.00002 秒を符号化しているのだ。取得したいサンプルの位置が秒数で分かっていれば、そのサンプルの浮動小数点数の配列の中でのインデックスを計算するのは非常に簡単である。

float time = 10; // 10 seconds into the song
float samplingRate = 44100.0f;
int index = (int)(time / samplingRate);

単純に、秒数で指定された時間をサンプリングレートで割り、その時点での振幅値を符号化したサンプルのインデクスを取得している。そして、この方法さえ知っていれば、PCM データをサウンドカードへ出力することができる。 (Java Sound のようなライブラリや Android の mediaframework は内部的にこのような処理を行っている。) Android では AudioTrack クラスを通して PCM データを直接サウンドカードへ送ることができるし、Java Sound にも似たようなメカニズムが存在している。

ちょっと遊びで 440Hz のモノラル音の PCM データを作成してみよう。この周波数は音楽では A という音符と同じものだ。音はふつうシヌソイド (sinusoid、正弦曲線) と呼ばれる波にほかならない。現実世界では音の信号はたくさんのこのようなシヌソイドの合計であり、合わさって音の信号の全体を形成している。A という音符に対応する音を作りたければ、1/440 秒の周期を持つシヌソイドを生成すればよい。

このグラフの x 軸は時間、y 軸はサイン波の振幅を表している。周期は 0.002 秒である。周期というのはここでは 1 つの頂点 (y 軸の正数側でも負数側のどちらでもよい) から次の頂点に行くために必要な時間のことである。

音符の周波数・サンプリングレート・音符の長さ (秒) を受け取り、音符に対応するモノラルの PCM データを生成する関数を書いてみよう。

public float[] generateSound( float frequency, float samplingRate, float duration )
{
   float[] pcm = new float[(int)(samplingRate*duration)];
   float increment = 2 * (float)Math.PI * frequency / samlpingRate;
   float angle = 0;
 
   for( int i = 0; i < pcm.length; i++ )
   {
      pcm[i] = Math.sin( angle );
      angle += increment;
   }
 
   return pcm;
}

コードの量は多くないが、いくつかの部分が何をやっているかよく分からないように見える。噛み砕いて説明してみよう。最初にやっているのは、PCM サンプルを保持するための浮動小数点数の配列を作成することだ。配列の長さはサンプリングレート (すなわち 1 秒ごとのサンプルの数) に音符の長さをかけることで求まる。音符の長さが 5 秒なら、44100 サンプル × 5 が必要となる。そんなに難しくはない。次の行では増分 (increment) を計算している。何の増分か? このあと Math.sin() に渡す角度の増分だ。前に書いた通り、ここで扱っているのはシヌソイド、つまりはサイン波だ。サイン波を生成するには、サイン関数を利用する。Java ではサイン関数はラジアン (radian、弧度) で角度を受け取り、その角度に対するサインを返却する。完全な円つまり周期は、2PIラジアンの長さ (角度で表すと 360° ) となる。1Hz の音がほしければ、44100 個のサンプルに対してラジアン 1 の完全な円を使えばよい。440Hz ではラジアン 440 の完全な円を使えばよい。等々。よって周波数と 2*PI をかけているのだ。サンプリングレートで割ると、現在のサンプルを導きだすために、現在の角度に足すべき最終的な増分が分かる。ループの中で、現在のサンプルに現在の角度のサインを設定し、以前に計算した値で角度を増加させている。そしてジャジャーン、シンセサイザーのコードを書くのに必要なことがすべて分かった。シンセサイザーというのは上記のコードに非常によく似た方法で PCM サンプルを生成しているだけだ。いくつかの関数を組み合わせることで、シンセサイザーは非常に興味深く複雑な音を生み出すことができる。だがそのすべての根幹にはサイン波 (またはノコギリ波か矩形波) が存在する。この関数に不足しているのは、生成されたサイン波の音の大きさ、つまりボリュームを操作する方法だ。Math.sin() 関数は単位円でのサインを返すので、[-1,1] の範囲で可能な限りの最も音の大きい振幅が得られる。もし生成された音を半分の音の大きさにしたければ、そのサインに 0.5 をかければよい。最大の音の大きさの 1/4 にしたければ 0.25 をかければよい。等々。とてもシンプルではないだろうか?

上記がこのシリーズの Part I の結論である。次回はフーリエ変換がどんなものかについて見てみよう。心配する必要はなく、高等数学はもちろん、サインすら使わない :)。さあ今は、Java の音声ライブラリを調べてみたり、自分で生成したサイン波を自分の耳で聞いてみたりしてみよう。