nikori.jp

Tout devrait être gitable

bynikorisoft

このエントリは、穏やかなぴょこりんクラスタ Advent Calendar 2025のために書かれたものです。案の定ギリギリになってしまいましたが、今年最後のアドベントカレンダーの記事です。

PCで音楽を作成するにはいろいろな方法が今はありますが、今回は古典的な楽譜などから音声データを作成する方法についてです。 ひと昔前(25年~20年くらい前でしょうか)においては、MIDIシーケンサーと呼ばれるようなソフトウェアでMIDIファイル(SMF, Standard MIDI File)を作成して、それをハードウェアないしソフトウェアのMIDI音源(いまのWindows 11でも、Microsoft GS Wavetable SynthがOSに付属しているのでMIDIファイルを再生することができますね。Macは知りません)と呼ばれるもので再生することで、音声データにするという手順であったでしょう。

現在は、MIDIシーケンサーはDAWソフトウェアとなり、MIDI音源はVSTプラグインとなりました。内部的にはMIDIの名残が残ってはいるものの、プラグインとのやりとりのメッセージの一部やエクスポートできるファイルの一部とかそういったところになってるかと思います。なお、ハードウェアにおいてはまだ残っているかと思いますが、今回はソフトウェアにフォーカスした話とします。

他方、現在においては、特に多くの人に共有されるものは、gitのようなバージョン管理システムで管理しやすいことが望ましいとされています。これは、差分などがわかりやすいことによって、多人数によるコラボレーションなどが容易になることが一番のメリットでしょう。他方、先に挙げたSMFや、DAWソフトウェアのファイルは、基本的にバイナリであり後者にあってはプロプライエタリであり各ソフトウェア次第です。これはとてもgit管理できるといえるものではありません。(バイナリをgitにただつっこむことをgit管理とはここで言いません)

ということで、今回の目標は、テキスト管理できる形で楽曲を作成し、モダンなVSTプラグインを使って再生してそれを音声ファイルにしようというものです。

背景

最初に背景となる技術についてまとめておきます。

VSTホストとプラグイン

まずはVST(Virtual Studio Technology)ですが、Steinberg社(CubaseというDAWで有名)が提唱した規格です。詳しくは、Steinberg社が公開しているSDKのドキュメントを見るのがよいと思いますが、先のMIDI音源のようなソフトウェア音源だけでなくオーディオのエフェクターというようなものをまとめて扱えるようにした規格という感じでしょうか。それぞれの音源やエフェクターといったものがVSTプラグインで提供され、DAWはそのホストとなってプラグインを呼び出して、それぞれの機能を利用するというものになります。

この関係をGemini(Nano Banana Pro)に適当に書いて生成したものが下記です。文字の崩れを抑えるために英語にしています。

48 1
パッと見は良さそうな関係図

MML (Music Macro Language)

次に楽曲ファイル自体をテキストで管理する方法を考えます。そこで、MIDIをさらに飛び越えてMMLというものが思い当たります。

MMLは、本当の起源は正直よくわかっていないのですが、少なくとも40年ほど前の(N88-)BASICくらいの時代には存在していた表現です。当時は、MIDIといった規格は1981年制定なのでぎりぎり存在こそしていたようですが、当時のパソコンで音楽を扱うためには、パソコン上のチップを利用もしくは専用のボードを接続する必要があったため、そのあたりはまだまだ独自規格だったと思います。OPN(Yamaha YM2203)とかOPNA(Yamaha YM2608)とか懐かしいですね。それをBASICから操作する際には、MMLという言語を使った指示文を介して行う必要がありました。

例としては、次のような形です。

T108O4C2DEF8G8A8B8>C2

これは、下記の曲をMMLで表現したものです。(拍子などは表現されていません。)

48 2
MMLの内容を譜面にしたもの

だいたい見てもらえれば想像がつくかと思いますが、 A から G はその音(A=ラ, …​)に対応し、その次の数字は音価を表します。Tは続く数字でテンポを指定するもの、Oはオクターブを指定します。 > や < は、次の音からオクターブを上下させるものです。

というのが基本ですが、シンプルであるがゆえに、規格化されていないので、やたら方言があるようです。 > と < は、意味が真逆な文法も存在するようで困ったものです。

今回はスタンダードっぽい文法は取り入れつつも、好き勝手に拡張してみたものを今回のテキスト管理で用いる表現としようと思います。スペースとかがないので、git管理しにくいと思われるかもしれませんが、まあ、そこはうまく行で区切るなり、文字単位のdiff機能も最近充実しているのでそれを使うなりすれば問題ないのではないかと思われます。はい。

設計

以上を踏まえますと、今回は、太古の技術であるMMLと現在主流のVSTを悪魔合体させるという内容になります。

今回は2つのパートからなる構成としました。

の2つです。それぞれソースを公開していますが、時間的余裕がなくREADMEすらないひどいものです。

中間表現としておとなしくSMFを使ったり、後者としてフリーであるVSTHostみたいなのを使うという手もありました。それだとおもしろくないというのもありますが、なるべく自動化・反復実行可能にしたかったのももう一つの条件としてありました。VSTプラグイン自体はオープンソースというわけにはなかなかいかないので、そこが一番のネックとしてのこってしまうものの、それ以外はある意味CIでも使えるようにしたいという気持ちによるものです。

プラグインの設定は、プラグインごとにGUIを使って設定する必要があり、その設定ファイルも内部構造はプロプライエタリのプラグインごとなのでブラックボックスとして扱わざるを得ません。そこで、それを扱うプログラムを設定プログラムを分離し、設定した内容を保存したものを使いまわすことにしました。楽曲自体よりは設定の操作のほうが頻度が低いだろうということ、曲中での制御はやっぱり楽曲自体で行うべきことからのデザインチョイスです。いや、それ以外に選択肢がないだけでもありますが。

実装

実装ですが、NxMMLのほうはTypeScript、VSTOutのほうはC++で行いました。

NxMML

NxMMLのパーサージェネレータとしては、Ohmを利用しました。今回定義した文法は、こちらにあります。いろいろ最適化されていないところではありますが、下記のような文法です。

文字列 意味

A, B, C, D, E, F, G

それぞれに相当する音。

R

休符

#, +

音名に付加されると、半音上げを意味する

-

音名に付加されると、半音下げを意味する。

[n]

音に付加されると、その音だけオクターブを指定する

1, 2, 4, …​

音や休符に付加されると、音価を表現する

.

音価に付加されると、音価を1.5倍にする

!

音に付加されると、次の音は同じ時刻に置かれる

&

タイ。前後の音をつなぐ

O n

これ以降の音のオクターブを指定する (初期値: 4)

{(音名)}

和音を表す。

T n

テンポを指定する。

M n/d

拍子を指定する。

K (音名)

キーを指定する。

L n

これ以降の音のデフォルトの音価を指定する (初期値: 4)

Q n

これ以降の音の音価に対して発音する長さの割合を100分率で指定する (初期値: 90)

V n

これ以降の音のVelocityを指定する (0-127) (初期値: 100)

P n

これ以降の音のPanを指定する (0-127) (初期値: 64)

|

小節区切りを表す。現在の時刻が小節の先頭でない場合、警告を表示する。連続すると、次の小節にスキップする

@ name (params)

拡張指示

拡張指示は、現在以下のものがあります。

指示名 パラメータ 意味

defineTrack

ID, name

ID という識別子を持ち、 name という名前の通常トラックを定義する

controlTrack

ID

以下の指示の対象を ID という制御トラックに切り替える。 ID は tempo と measure のみ指定可能。

track

ID

以下の指示の対象を ID という通常トラックに切り替える

timeUnit

unit

このファイルにおける、全音符あたりのtick数(小節未満の時間単位)を unit に変更する。

measure

measure

以下の音や指示を指定された小節番号 measure から開始する。 measure は0オリジンである。

abs

measure, tick

次の音のみ指定された measure (小節) 、tick に置く。

ということで、RavelのBoleroの冒頭のフルートとヴィオラのパートだけ書いてみたのが、リポジトリのサンプルにもありますが、下記のような感じです。

#----------------
@defineTrack(Flute1, "Flute 1")
@defineTrack(Viola, "Viola")

@timeUnit(9600)
#------------------

@controlTrack(tempo)
T72

@controlTrack(measure)
M3/4

#------------------
@track(Flute1)

@measure(4)
O5 Q100 C4.<B16>C16D16C16<B16A16 | Q90 >C8 Q100C16<A16>C4.<B16>C16 | A16G16E16F16G2 | Q90 &G16

#------------------
@track(Viola)

O3
@abs(0, -200)
F#[-1]64!

@measure(0)
O3 G4 {G>G}4R4 | G4 {G>G}4R4 | G4 {G>G}4R4 | G4 {G>G}4R4 |

O3 G4 {G>G}4R4 | G4 {G>G}4R4 | G4 {G>G}4R4 | G4 {G>G}4R4

ここから、それぞれのトラックに対して、実際のタイミングにどの音をどの長さで出すかを表した中間表現を出力するのが、nxmml プログラムとなります。(ここのロジックはだいぶ最適化されていないので、長大なものを入れるとどんなことになるかは想像がついていません)

中間表現はYAMLで、抜粋すると下記のような形です。

- id: Flute1
  name: Flute 1
  notes:
    - time: 960000
      duration: 118800
      note: 72
      velocity: 100
    - time: 1080000
      duration: 19800
      note: 71
      velocity: 100

SMFと比較するとだいぶ富豪的な表現(バイト数的に)ですね。

VSTOut

VSTOutのほうは、SteinbergがVST SDKを出していることもあり、それを直接利用することも考えたのですが、より簡単なJUCEを使うことにしました。

久しぶりのC++で、しかもスマートポインタの類も初心者なので、どっかで盛大にメモリリークしてそうな気もするのですがそこはご容赦ください。いや、許されるような簡単な問題ではないと思いますが。

ConfigAppのほうは正直JUCEのサンプルとほぼ同様なので、SynthAppのほうを見ていきます。 といっても、こちらもシンプルで、

bool initPlugin(AudioPluginFormatManager &manager, Instance &instance, Config &config) {
//...
    instance.processor =
        manager.createPluginInstance(*found[0], config.output.samplingRate, DEFAULT_SAMPLE_SIZE, error);
//...
    ifstream stateFile(instance.state, ios::binary);
//...
    shared_ptr<char[]> data(new char[size]);
    stateFile.read(data.get(), size);
    stateFile.close();

    instance.processor->setStateInformation(data.get(), static_cast<int>(size));
//...
}

JUCEの機能でプラグインを読み込み、あらかじめConfigAppのほうで作っておいた設定ファイルをステート情報として与えるだけです。

そして、もろもろの初期化を行い、あとはループで一定時間ずつ MidiBuffer に発音する音と時刻の情報をプラグインに与えて再生し、`AudioBuffer`に出力される音声データをWAVファイルとして保存していくだけです。

    juce::AudioBuffer<float> buffer(std::max(numInputChannels, numOutputChannels), blockSize);
    juce::MidiBuffer midiBuffer;
//...
    auto eventIter = instance.events.begin();
    while (samplesRendered < totalSamplesToRender) {
        midiBuffer.clear();
        buffer.clear();

        int numSamplesThisBlock = std::min(blockSize, totalSamplesToRender - samplesRendered);

        while (eventIter != instance.events.end()) {
            auto event = *eventIter;
            long time = static_cast<long>(event.time * sampleRate / config.timebase);

            if (samplesRendered <= time && (samplesRendered + numSamplesThisBlock) > time) {
                int offset = static_cast<int>(time - samplesRendered);
                if (event.type == MIDI_NOTE_ON) {
                    midiBuffer.addEvent(
                        MidiMessage::noteOn(event.channel, event.note, static_cast<uint8>(event.velocity)), offset);
                } else {
                    midiBuffer.addEvent(
                        MidiMessage::noteOff(event.channel, event.note, static_cast<uint8>(event.velocity)), offset);
                }
            } else {
                break;
            }
            eventIter++;
        }

        plugin->processBlock(buffer, midiBuffer);
        writer->writeFromAudioSampleBuffer(buffer, 0, numSamplesThisBlock);

        samplesRendered += numSamplesThisBlock;
    }
//...

基本はこれで完了です。

SynthAppに与えるYAMLは、以下のような設定ファイルとなります。

config:
  timebase: 96000
  preroll: 24000
  postroll: 96000
  output:
    samplingRate: 48000
    bits: 16

instances:
  - plugin: "C:\\Program Files\\Common Files\\VST3\\BBC Symphony Orchestra (64 Bit).vst3"
    state: "bbc.flute.bin"
    id: flute1
  - plugin: "C:\\Program Files\\Common Files\\VST3\\BBC Symphony Orchestra (64 Bit).vst3"
    state: "bbc.viola.bin"
    id: viola

map:
  Flute1:
    instance: flute1
    channel: 1
  Viola:
    instance: viola
    channel: 1

tracks: ./bolero.tracks.yaml

config でサンプリングレートや前後のマージンとなる時間とか諸々の設定を行います。次の instances では、プラグインのインスタンスの設定をしています。それぞれプラグインのパスと、ConfigAppで作ったステートファイル、それからIDを指定しています。そして、 map として、トラックとインスタンス・チャネルの関係を指定します。(なお、今回の例は、マルチティンバーのプラグインではないので、トラックごとにインスタンスを作る必要がありました)。最後に tracks として先ほどNxMMLで出力したYAMLファイルを指定するという感じです。

以上により出力された音声ファイルは、トラックごとの音声なので、ffmpegでもなんでも使ってミックスしてあげれば、作りたかったものが完成します。

生成した音声ファイル (Flute + Viola)

これで、作りたかったものの基本機能を実現することができました!

未解決の問題

初期化問題

・・・なのですが、このプログラムには重大な問題があります。

注意深く見てもらえればわかるかもしれませんが(実行すれば一目瞭然ですが)、

    std::cout << "Warming up plugin..." << std::endl;
    Thread::sleep(30'000);

という、なんだこれはというコードがあります。そう。このプログラムはVSTプラグインをロードした後30秒間停止します。

これはVSTプラグインの性質なのですが、基本的にリアルタイム処理を前提としたつくりとなっています。そのため、プラグイン側の準備ができていなくても処理を停止するということはしません。できていない場合には、何の音も出さずに済ませてしまいます。 そして、サンプリングベースのVSTの音源は、ロードしたときにディスクからサンプリングデータを読み込みます。そのデータは、人の音色であっても数十MBからGB単位に及ぶこともあります。そんなのは、SSDであっても数秒かかっても不思議ではありません。

つまり、ここでウェイトを入れておかないと、初期化のあとにすぐ演奏しようとして無音のWAVファイルが生成されるという悲しい思いをすることになります。(実際、作成時はしばらく悩みました)。

そして、自分の調べた限り、この読み込みが完了したかどうかはホストアプリケーションから知るすべはなさそうです。そういうAPIがVSTのSDKレベルでも定義されてないように見えます。

いちおう気休め程度に

plugin->setNonRealtime(true);

というコードを入れてありますが、何にも機能していないように見えます。

毎回WAVを作るたびにVST初期化をしているやり方は結局よくないということで、サーバーというかデーモン型にして、プラグインの読み込みだけはあらかじめやっておき、変更がある場合に再初期化するというような設計にする必要がありそうです。実際、DAWはそういう動きをしているので、やむを得ないところですね。

プロプライエタリ(もしくはレガシー)が自動化させてくれないという悲しい問題がここにあります。

プラグインごとの挙動の違い

これもまたどうにもならないのですが、VSTプラグインごとに微妙な挙動の違いがあります。上記の初期化にかかる時間はもちろんまちまちですし、MidiBufferにまったく同時刻のイベントを複数追加するとうまく処理してくれないものとかもありました (どちらかのイベントの時刻を+1すると正常に動くものとか)。今回は、中間ファイルを手でいじることで回避したりもしましたが、これもプログラム側で対処しておくべきことでしょう。

今後の機能

多分真面目に今後曲を書こうと思うと、もっといろいろな表現(グリッサンドどうするのとか、音ごとに強弱や長さを微妙に変えるとか)ができる必要があると思うので、そういう際にはマクロのようなものが使えると便利だと思うので、プリプロセッサのようなものを導入する必要がありそうです。そういったものをテキストベースで作れれば再利用も容易ですし、本来の目的にも叶うと思うのですよね。

あとは、MMLの文法で定義したものの、実は実装されていない機能もあるので、それはちゃんとやりたいところです。

まとめ

今回の記事は、MML → VST Pluginという時間を越えた何かを実現しようとしたら、そもそも設計思想時点で無理筋だったのではないかという問題にあたってしまったというものでした。まあ、デーモン化するなりなんなりでなんとか回避できそうな問題なので、そこまでの問題ではないですが、ちょっときれいではないのが残念なところです。

なんにせよ、久しぶりにC++を書いたり、ブラックボックスだったVSTの仕様を垣間見たりすることができたのはよかったと思います。