はじめに
VGS-Zero(Video Game System - Zero)で利用できるソフトウェアシンセサイザーVGS(Video Game Sound)は、MML コンパイラ(vgsmml)でコンパイルしたバイナリ(.bgm)を再生する仕様にしていましたが、MMLの仕様が結構独特(クセが強い)という懸念点があります。
MML は、ABCDEFG で ラシドレミファソ の音程、数字を付与することで 1 なら 全音符、2なら半音符、4なら四分音符といった形で音の長さを表すなど、不文律のほぼ統一された規格 が存在しますが、共通規格は無い(と思われる)ので多くの方言が存在します。
例えば VGS の MML なら、%
命令でキーオフタイミングを設定したり、K
命令で以降の音を半音上げ下げ、P
命令で自動ピッチダウンの設定、3分音符や999分音符(最大22050分音符)などの楽典に存在しない音符の長さ(全音符の除数)を記載可能など、他の MML では見られない特徴を挙げればキリがありません。
一方、VGS のサウンドドライバが理解できる「ノート」と呼ばれるデータ(vgsnote)は、とても簡素な仕組みになっています。
そこで、vgsnote と BGM データ(MMLコンパイラでコンパイルされたデータ)のバイナリフォーマットの仕様をドキュメント化して VGS-Zero のリポジトリで公開してみました。
このドキュメントは、MIDI などのデータを VGS で扱えるデータに変換するコンバータなどを開発する方々(上級者)に向けて公開しましたが、これを読めば「ソフトウェアシンセサイザーの仕組み」を理解することにも有用かもしれません。
本書では VGSBGM.md を基に、ソフトウェアシンセサイザーとしての VGS; Video Game Sound の仕組み を解説します。
自作のソフトシンセを創ってみたいという好事家諸氏の参考になれば幸いです。
プログラムでの音の鳴らし方
コンピュータの音は、サウンドカードにパルス符号変調(PCM)のデータを書き込むことで発音できます。
PCM 出力の HAL; Hardware Abstraction Layer には様々な API が存在しますが、SDL (Simple Direct-media Layer) が一番シンプルで扱いやすいので、自作ソフトシンセを創ってみたい方は、SDL2 を使ったプログラムのビルド環境を整えると良いかもしれません。
SDL 以外にも、DirectSound (Windows)、AudioQueue(iOS, macOS)、ALSA(Linux, Android)など色々な HAL が存在しますが、SDL はそれらを全て抽象化しているので、SDL で書いたサウンドプログラムは Windows、macOS、Linux で共通化できます。
SDL が(多分)対応していないものとしては、Android の AudoTrack (Java)、Arduino Framework(M5Stack や RaspberryPi Pico など)の I2S などがありますが、それらもだいたい同じような使い方なので、SDL で使い方を学習することでそれらの理解も容易になります。
音声を再生する機能がある OS や VM には、似たような HAL が だいたい 存在します。
以前、Flutter を調べた時には見当たらなかったので、全ての OS や VM にある訳ではないようです。
VGS-Zero では一応効果音再生の仕組みを応用することで raw streaming PCM を実現可能なので「VGS-Zero には一応存在する」と言えるかもしれませんが、Flutter と同様に API としては提供していません。(ゲームを創るには必須ではないと思われますし)
ちなみにファミコン本体にも raw streaming PCM を再生する機能があります。(かなりマニアックなので使ったゲームは多くありませんが、イギリスのレア社が創ったBattle Toadsのタイトルのドラム音などで使われています)
ビルド環境の構築(C++ & SDL2)
Ubuntu では以下のコマンドを実行することでビルド環境が整います。
sudo apt install build-essential libsdl2-dev libasound2 libasound2-dev
Windows や macOS でも同じような環境を整えることが可能です。
ただし、それらの OS は Ubuntu と比べて操作が若干面倒くさい(説明するのが野暮ったい)ので、本書は ビルド環境 = Ubuntu (C++ & SDL2) の前提で解説します。
サウンド出力の初期化
VGS-Zero の SDL2 版エミュレータのコード(vgs0sdl2.cpp)を例に解説します。
log("Initializing AudioDriver");
SDL_AudioSpec desired;
SDL_AudioSpec obtained;
desired.freq = 44100;
desired.format = AUDIO_S16LSB;
desired.channels = 1;
desired.samples = 735; // desired.freq * 20 / 1000;
desired.callback = audioCallback;
desired.userdata = &vgs0;
auto audioDeviceId = SDL_OpenAudioDevice(nullptr, 0, &desired, &obtained, 0);
if (0 == audioDeviceId) {
log(" ... SDL_OpenAudioDevice failed: %s", SDL_GetError());
exit(-1);
}
log("- obtained.freq = %d", obtained.freq);
log("- obtained.format = %X", obtained.format);
log("- obtained.channels = %d", obtained.channels);
log("- obtained.samples = %d", obtained.samples);
SDL_PauseAudioDevice(audioDeviceId, 0);
SDL_AudioSpec
に
- 出力する PCM のデータ形式
- コールバック関数ポインタ
- コールバック関数の引数
を指定して SDL_OpenAudioDevice
を発行すればオーディオデバイスを初期化できます。
なお、ハードウェアでサポートしていないデータ形式の場合、近似するものが設定されて
obtained
に実際の設定値が返ります。
そして、SDL_PauseAudioDevice
で音声を再生状態(0: 非ポーズ)にすることで、音声データ(PCM)をバッファリングすべきタイミングでコールバック関数が呼び出されます。
使用する SDL2 の API は以上です。(もちろん、終了処理も必要ですが解説は省略します)
サウンド出力のコールバック
static void audioCallback(void* userdata, Uint8* stream, int len)
{
VGS0* vgs0 = (VGS0*)userdata;
pthread_mutex_lock(&soundMutex);
if (halt) {
pthread_mutex_unlock(&soundMutex);
return;
}
void* buf = vgs0->tickSound(len);
memcpy(stream, buf, len);
pthread_mutex_unlock(&soundMutex);
}
引数 stream
の領域に引数 len
で指定されたサイズ(単位: byte)の PCM を書き込むことで、音声を再生されます。
正確にはコールバックをリターンすると
stream
が再生キューに入ります。
vgs0sdl2.cpp
では、VGS-Zero エミュレータの tickSound
を実行することで Video Game Sound や効果音(raw PCM)の波形データを生成してバッファリングしています。
VGS-Zero エミュレータは、Z80 CPU と VDP を動かす
tick
と音声処理を動かすtickSound
を別にしています。
次章では、この tickSound
の内部で実行している処理を理解するために必要な波形の基礎を解説します。
波形の基礎
波形とは、サウンドカードに書き込まれた PCM の値を時系列にグラフ化した形状のことで、その形状により音色、音量、音程が決まります。
音色
音色(トーン)は波形の形により決まります。
Video Game Sound では、三角波、ノコギリ波、矩形波、ノイズの 4 種類の波形をサポートしています。
上図はデューティー比(16bitsの正と負の割合)が全て均等(5:5)ですが、デューティー比を変えることで音色が大きく変わります。例えばファミコン音源(RP2A03)には 2 系統の矩形波チャネルがありますが、デューティー比を変えることでノコギリ波に近い音色を発声することができます。
音量
音量(ベロシティ)は波形の大きさの大小で決まります。
大きな波形なら大きな音(高音圧)になり、小さな波形なら小さな音(低音圧)になります。
音程
音程(ピッチ)は、波形周期を1秒間に何回繰り返すのかで決定します。
- 1秒間に 440回 (440Hz) = ラ
- 1秒間に 880回 (880Hz) = 1より1オクターブ高いラ
- 1秒間に 220回(220Hz)= 1より1オクターブ低いラ
合成
複数の波形を足し算すれば音を合成でき、和音などを再生することができます。
波形データの作り方 (Video Game Sound)
Video Game Sound では、サポートする 4 つの音色(三角波、ノコギリ波、矩形波、ノイズ)の全音階(84音階)の 1Hz 分の 波形データ(基礎波形)を全てテーブル(オンメモリ)に持ち、テーブル参照により求めた基礎波形にボリューム値とエンベロープ値(パーセンテージ)を掛けることで出力波形を生成しています。
これは一種の波形メモリ音源です。
ただし、一般的な波形メモリ音源(PC-エンジンの内蔵音源、KONAMI の SCC、ピストンコラージュなど)は基礎波形データを基に伸縮することで音程を決定しているため、一般的な波形メモリ音源の処理方式とは若干異なります。
当初、VGSは iPhone 3GS (ARMv7 600MHz) での快適な動作を目指していたため、計算量を最小限にしたいと考えました。そこで、波形の伸縮計算を省略することで CPU の総計算量が少なくなる「全ピッチのテーブル化」を採用しています。
「全ピッチのテーブル化」をするデメリットとしてメモリ専有量が大きくなるため、波形の種類を追加することが困難になりますが、その点は 4 波形に絞るソリューションで解決しました。
4 波形 × 84 音階の 1Hz の波形データであれば、大容量の iPhone 3GS の RAM (256MB) であればオンメモリで余裕をもってテーブルを持つことができます。
また、Video Game Sound は RaspberryPi Pico (CPU 133MHz + RAM 256KB) の環境でも問題なくエミュレーションできています。
エンベロープ
単純に波形出力の ON/OFF で音声出力制御を行うと音の切り替わりがとても不自然な形になってしまうので、波形の大きさの増幅と減衰を制御するエンベロープと呼ばれる処理を行う必要があります。
Video Game Sound では、音の再生指示(KEYON)が実行されると指定された時間(ENV1)までの間に音量 0% の状態から 100% にリニア増幅を行い、停止指示(KEYOFF)が実行されると指定された時間(ENV2)までの間に現在の音量から0%にリニア減衰を行います。
一般的なシンセサイザーのエンベロープ(ADSR)には Decay と呼ばれる Attack 後に Sustain までの減衰期間が指定できますが、Video Game Sound では Decay を省略しています。(Decay が無いと困る場面が想像できなかったので機能をシンプルにするため省略してます)
音源ドライバの仕組み
VGS の音源ドライバは次の 10 種類のノート(vgsnote)と呼ばれる制御命令を逐次処理(シーケンス処理)することで音楽の再生制御を行っています。
- ENV1 ... 開始エンベロープ
- ENV2 ... 終了エンベロープ
- VOL ... チャネル別ボリューム
- KEYON ... キーON(発声指示)
- KEYOFF ... キーOFF(発声終了指示)
- PDOWN ... ピッチダウン
- MVOL ... マスターボリューム
- JUMP ... ジャンプ(ループ)
- LABEL ... ラベル
- WAIT ... ウェイト
WAIT 以外のノートを検出するとコンテキストデータの更新を行い、WAIT を検出すると WAIT で指定された時間だけコンテキストデータに従って各チャンネルの波形生成と合成を行います。
あとがき
以上が Video Game Sound の仕組みの全てです。
驚きのシンプルさです。
実は KoBuSi モードと呼ばれる音を揺らす特殊な機能もあるのですが、最新の仕様では deprecated にしました。
音楽クリエイターの視点では、「もっと多くの機能が欲しい」と考えるかもしれませんが、私は専門外なので、専門外の私でもなるべく簡単に音楽を実装できることを目指して設計した節があります。
その副産物として「自作ソフトシンセの入門用途」としても最適なものになったかもしれません。
是非とももっと面白いソフトシンセを創ってみてください。