サウンドプログラミング初心者が個人的な興味からスマホ向けカラオケアプリを実装してみたので、実装内容を紹介したいと思います。
今回は技術検証が主な目的なので、以下のカラオケにありそうな機能をいくつか実装してカラオケっぽいものを作る事にしました。
完成したアプリの動画はこちら
Superpoweredという低レイテンシーを売りにしたマルチプラットフォーム対応のサウンドエンジンを使用しました。 採用した理由は低レイテンシーな所です。
今回リアルタイムに音階を解析するので、レイテンシーが遅いと遅延を考慮した判定処理が必要になり実装難易度が上がってしまうためです。
レイテンシー以外にもSuperpoweredには波形を処理するための便利な関数がいくつか用意されていたで、サウンドプログラミング初心者の私には助かりました。
ちなみにSuperpoweredの他にもFMODというサウンドエンジンも検証しましたが、レイテンシーの面で満足行く結果が出なかったので不採用となりました。
ここからはカラオケの実装に必要な楽曲の再生方法・テロップ表示・楽曲とテロップの同期処理・音階判定の具体的な実装について紹介していきたいと思います。
オーディオファイルフォーマットはoggを使用して楽曲の再生を行いました。
ファイルフォーマットは特にこだわりはないです。
ノーツと歌詞の表示は、XFというヤマハが作ったSMF(Standard MIDI Files)と互換性のあるファイルフォーマットを使用しました。 XFの仕様書を見ると「歌詞はカラオケのテロップ表示をターゲットにしている...」とあり、カラオケのテロップ表示に最適化されているのと、SMFと互換性もあるため音階・時間等の情報も持たせられるファイルフォーマットになっています。
XFで定義されているカラオケのテロップ表示用のデータは以下のようなものがあります。
[Tips] SMFの拡張領域の活用
XFではSMFの拡張領域に歌詞テロップ等の拡張情報を追加する事でSMFとの互換性を保っています。
例えば、SMFの拡張領域に音ゲーのスコア情報をノート単位で持たせるような使い方もできそうです。
今回は個人的に使いやすそうだった『XF Format Tool』を使用しましたが、XF Format Toolは現在公開停止となっています。
XFのパーサーは見つからなかったため、オープンソースのSMFパーサーをカスタマイズしてXFパーサーを実装しました。
XFはSMFと互換性があるので歌詞情報のパース処理を追加実装するだけで、今回の実装に必要な情報を全て読み込む事ができました。
関連リンク
XFフォーマット仕様書
https://jp.yamaha.com/files/download/other_assets/7/321757/xfspc.pdf
SMF仕様書
https://sites.google.com/site/yyagisite/material/smfspec
SMF仕様書(日本語翻訳)
http://amei.or.jp/midistandardcommittee/MIDI1.0.pdf
XF(SMF)はノート(音符)ごとに時間と歌詞情報を持っているので、 oggの現在の再生位置(時間)と比較して音楽と歌詞の同期を行いました。
Superpoweredではマイクから入力された波形データが一定間隔で取り込まれますが、
波形データから音階を解析するには以下のような処理を行う必要があります。
Superpoweredでは上記の処理を行ってくれる便利な関数が用意されているので、以下のように簡単に実装する事ができます。
以下のサンプルコードでは1〜3の処理を行ってくれるSuperpoweredFrequencyDomainクラスを使用しています。
static Superpowered::FrequencyDomain *frequencyDomain;
// オーディオエンジンによって定期的に呼び出さる関数で引数にはマイク入力された波形データが含まれます。
static bool audioProcessing (
void * __unused clientdata,
short int *audioInputOutput, // マイク入力された波形データ
int numberOfFrames,
int __unused samplerate
) {
// 波形データをshortからfloatに変換
Superpowered::ShortIntToFloat(audioInputOutput, inputBufferFloat, (unsigned int)numberOfFrames);
// 波形データをバッファリング
frequencyDomain->addInput(inputBufferFloat, numberOfFrames);
// FFT(高速フーリエ変換)を予め設定した時間で区切って処理します
while (frequencyDomain->timeDomainToFrequencyDomain(magnitudeLeft, magnitudeRight, phaseLeft, phaseRight)) {
// 引数のデータはFFT(高速フーリエ変換)された結果が格納されていて、周波数を取得する事ができます。
// 0-430 Hzの周波数データをカットします
memset(magnitudeLeft, 0, 80);
memset(magnitudeRight, 0, 80);
// 一番音の強さ(dB[デシベル])が大きい周波数を取得します
int max_bin = 0;
float max_db = 0.0f;
for (int bin = 0; bin < frequencyDomain->fftSize; bin++)
{
float db = (magnitudeLeft[bin] + magnitudeRight[bin]) * 0.5f;
if (db > max_db) {
max_db = db;
max_bin = bin;
}
}
float bin_size = samplerate / frequencyDomain->fftSize * 0.5f;
frequency = (float)max_bin * bin_size;
周波数が解析できたら次に楽曲の音階の判定ですが、
XF(SMF)の現在の再生位置の音階と、マイク入力された周波数と比較して判定を行います。
XF(SMF)では以下の表のように音階ごとに周波数が決まっているので、マイク入力された周波数と近い周波数かどうかで判定が行えます。
※この時マイク入力のレイテンシーが高いと再生中の音とズレが生じて遅延を考慮した判定処理が必要になり実装難易度が上がってしまいます。
XF(SMF)の音階と周波数の対応表(一部抜粋)
音階 | 周波数 |
---|---|
C4 | 261.6 |
C#4 | 277.2 |
D4 | 293.7 |
D#4 | 311.1 |
E4 | 329.6 |
F4 | 349.2 |
F#4 | 370.0 |
G4 | 392.0 |
G#4 | 415.3 |
A4 | 440.0 |
A#4 | 466.2 |
B4 | 493.9 |
こちらの動画のマイクアイコンが上下に動いていますが、今回で解析した周波数を使用して動かしています。
マイクはノイズも拾ってしまうためノイズカットして余計な処理をしないようにします。
今回は人間が発生出来ないような低周波を除去したり、ボリュームが小さい時は音階判定処理をしないようにしました。
今回、カラオケというゲーム開発とは関連性が低そうな事をやってみましたが、 実際に実装してみてリアルタイムリップシンクやボイスチャット等について実装イメージが湧くようになったので個人的には良い学びになりました。
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。
合わせて読みたい
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。