fn onepole(x, ratio){
x*(1.0-ratio) + self*ratio
}
selfは0で初期化され、その関数の1サンプル前の返り値を参照するfn biquad_inner(x,a1,a2){
let (ws, wss, _wsss) = self
let w = x - a1*ws - a2*wss
(w, ws, wss)
}
pub fn biquad(x,coeffs){
let (a1,a2,b0,b1,b2) = coeffs;
let (w,ws,wss) = biquad_inner(x,a1,a2);
b0*w + b1*ws + b2*wss
}

selfは多相的(その関数の返り値型と同じ型になる)fn fbdelay(input, time, fb, mix){
input * mix + delay(40001, input + self * fb, time) * (1 - mix)
}
delayは組み込み関数。引数は3つ: 最大ディレイ長、入力信号、ディレイ長use math::*
fn phasor_zero(freq){
(self + freq/samplerate)%1.0
}
pub fn phasor(freq,phase_shift=0){
(phasor_zero(freq) + phase_shift)%1.0
}
pub fn sinwave(freq,phase=0){
phasor(freq,phase)*2.0*PI() |> sin
}
samplerateはランタイムが定義する環境変数|>はa(b)をb |> aと書く記法 — 関数型的な信号のデータフロー記述と相性が良い#stage(macro)
let detune_table = [128,-128,408,-417,704,720]
let numbers = detune_table |> len |> lift
let detunepitches = map(|x| x /(2^7),detune_table)
fn supersaw(){
let init = `|freq,detune|{ saw(freq,0) / $numbers}
foldl(detunepitches,init,|elem,acc|{
`|freq,detune|{
let f = freq + $(elem|>lift) * detune
($acc)(freq,detune)+saw(f,0) / $numbers
}
})
}
`): 式を評価せず「コード値」として扱う$): コード値をコードの中に埋め込む型判断にステージ
liftで明示#stage(macro/main)アノテーションで命令型っぽくステージを切り替え(実際にはクォートとスプライスの糖衣構文)foo!(bar)はマクロ呼び出し$(foo(bar))の糖衣構文#stage(macro)
let numbers = detune_table |> len |> lift // ステージ0で計算し、コードへ持ち上げ
fn supersaw(){
`|freq,detune|{ saw(freq,0) / $numbers } // 引用の中はステージ1の式
...
}
cons/listに展開されることの型付き版 = Code Combinator`{ saw(f, 0) }
// ↓ 型検査後の変換(イメージ)
mkapp(mkvar("saw"), [mkvar("f"), mklit(0)])
Cf. Kiselyov, "MetaOCaml: Ten Years Later" (SCP, 2026) / Kameyama, Kiselyov & Shan, "Combinators for Impure yet Hygienic Code Generation" (2015)
use core::*
use mininotation::*
use osc::*
use env::adsr
use delay::stereo_delay
use filter::lowpass
fn dsp(){
let note = run_note!(mini("60 <62 72> <[64 [74 72] 65] [65 62]> ~ 69")
||> legato(0.2,_), 1)
saw(note.val-12 |> midi_to_hz, 0.0)
* adsr({attack = 0.001,release=0.1,sustain=0.9,gate=note.gate})
||> lowpass(_,((sinwave(0.1,0)+1)*500+400),4)
}
mini関数の中の文字列は、mimium上に埋め込まれたDSL。パーサコンビネータ自体がmimiumで書かれている
use ...
let notes = [60,62,64,67,72]
fn melody(cps){
let l = notes |> len
let phase = phasor(cps/(l-1),0)
let i = phase * l |> floor
let freq = notes[i] |> midi_to_hz
let gate = {gate = (phase*l%1) < 0.2} |> adsr
let ff = (sinwave(1,0)+1)*1000+200
saw(freq,0)
||> lowpass(_,ff,4)
||> _ * gate
}
let cps = 2
fn dsp(){
melody(cps)
|> tostereo
||> pingpong_delay(_,0.5,0.3,1.0,0.5)
}
fn fbdelay(input, time, fb, mix){
input * mix + delay(40001, input + self * fb, time) * (1 - mix)
}
fn dsp(input){
input
||> fbdelay(_, 2000, 0.9, 0.5)
||> fbdelay(_, 3000, 0.7, 0.5)
||> fbdelay(_, 5000, 0.5, 0.2)
}
a ||> foo(_,b,c)はコンパイル時に展開されるパイプと部分適用
$(a |> |x| `foo($x,b,c) )の糖衣構文:すなわちfoo(a,b,c)fbdelayはそれぞれ別のインスタンスとして解釈される必要があるfn fbdelay(input, time, fb, mix){
...
let s = get_self();
shift_state_position(1);
...
update_ringbuffer(...);
shift_state_position(-1);
...
let ret_value = ...
set_self(ret_value);
ret_value
}
fn dsp(input){
let a = fbdelay(input,2000,0.9,0.5);
shift_state_position(40004);
let b = fbdelay(a, 3000,0.7,0.5);
shift_state_position(40004);
let c = fbdelay(b,5000,0.5,0.2);
shift_state_position(-80008);
c
}
dsp関数の呼び出し木の差分を取るState ::= Feed(Type)
| Mem(Type)
| Delay(Type,Max_Time)
| DirectFnCall(Vec(State))
fn phasor(freq){
(self+(1/freq))%1.0
}
fn osc(freq){
- phasor(freq )* 2 * PI |> sin //case a
+ phasor(freq+(phasor(freq/10))) * 2 * PI |> sin //case b
}
fn fmosc(freq,rate){
osc(freq + osc(rate))
}
fn dsp(){
- fmosc(440,10) + osc(880) + fmosc(1320,10)//case 1
+ fmosc(440,10) + fmosc(1320,10)//case 2
}
それぞれの「パッチ」はmemcpyのような操作(コピー元、コピー先、データサイズ)
こんにちは、松浦知也です。今日は私が開発している、信号処理のための関数型プログラミング言語mimiumについて話します。関数型まつりということで、今日は言語の全体的な紹介よりも、mimiumの基盤となる計算体系、特に型付きマクロを実現する多段階計算の体系と、それらの設計から生まれたライブコーディング機能の仕組みに焦点を当てて話します。
自己紹介です。去年まで大学で研究とプログラミング教育の仕事をしていて、今は独立研究者、要するにほぼ無職です。ミュージシャンとして自作楽器での即興演奏をしたり、プログラミング言語の研究を続けています。自分の研究領域を「音楽土木工学」と呼んでいます。音楽テクノロジーのインフラ的なレイヤーを、実践者の批評的な視点から作り直すことを試みる架空の学問領域です。
mimiumは信号処理のための関数型プログラミング言語で、汎用言語の上に音楽向けの最小限の機能を実装したものです。もともとC++で開発していましたが、現在は完全にRustで書かれていて、構文もRust風です。ただし実際の意味論はもっと関数型寄りです。複数のバックエンドで動作します。今日は言語全体のデモは省略しますが、Webエディタはブラウザですぐ試せるので、興味があれば触ってみてください。
ブラウザ上でも動きます。WebAssemblyを使ってリアルタイムに音を出せるWebエディタがあるので、今日紹介するコードは手元ですぐ試せます。
今日のロードマップです。まず言語を開発した動機と設計原理について話します。次にコアとなる計算体系λmmmと、基本的なDSPアルゴリズムをmimiumで書く例をいくつか見せます。その後、今日のメインテーマの1つである多段階計算——型安全なマクロの計算体系——について詳しく説明します。続いてmimiumのもう1つの特徴的な機能であるライブコーディングと、それを支えるランタイムの実装について説明します。最後にパフォーマンスのベンチマークを紹介してまとめます。
mimiumの設計を始めたとき考えていたのは、音楽プログラミング言語は複雑すぎる、ということでした。MaxやSuperCollider、ChucKを学び始めると、何百もの組み込みプリミティブ関数と大量のUnit Generatorを覚える必要があります。そしてそれらの多くは言語自身の上に作られているのではなく、CやC++で実装されていて、内部構造はブラックボックスです。なぜこんなに複雑なのでしょうか?
言い換えるとこうなります。なぜ音楽プログラミング言語は、普通のプログラミング言語のようにではなく、つぎはぎ的に作られているのか? ドメイン特化言語を作ろうとしているのに汎用性を求める、というのはやや逆説的な問いですが、これはあらゆるDSLが潜在的に抱える矛盾でもあります。
音楽プログラミング言語、コンピュータ音楽言語には長い歴史があります。もともとの目的は電子計算機で音楽を演奏することで、1980年代以降はリアルタイム実行できるシステムとして開発されてきました。初期の言語はディスプレイもマウスもない時代に生まれたので、ある意味プログラミング言語をインターフェースにするのが唯一の選択肢でした。その観点からすると、1990年代以降に開発された言語は、あえてプログラミング言語という迂遠なインターフェースを選んだと見ることができます。そして多くの音楽プログラミング言語が、こうした伝統と慣習を今日まで引き継いでいます。
今日のOS環境でオーディオ言語を作るには多くの制約があります。リアルタイム安全性のためには動的メモリ確保を極力減らし、スレッドを正しく同期させる必要があります。一方で、現代のプログラミング言語はこうした関心事に対する抽象化を備えています。だとすれば、ゼロから新しい言語を設計するのではなく、既存の言語のライブラリやフレームワークとしてオーディオDSLを作れるのではないでしょうか?
実際、SuperColliderの原作者James McCartneyがこれについて興味深いコメントを残しています。(読む) 彼は続けて、SuperColliderをバックエンドとして使うSonic Piのようなクライアント側の言語は実際Rubyのライブラリとして実装されている、と述べています。しかし、音響合成エンジンそのもの——SuperColliderのような部分——を汎用言語のライブラリで置き換えた例は今のところ存在しません。GCや遅延評価の話が出てくるあたり、関数型言語に馴染みのある皆さんには刺さる話ではないでしょうか。
では2026年現在、状況はどうでしょうか。残念ながら、オーディオDSLをライブラリとして作るための要件をすべて満たす汎用言語はまだ存在しません。C、C++、Rustは注意深い手動のリソース管理が必要です。GoのようなGC付き言語は、GCがランダムにプロセスをブロックするのでリアルタイムオーディオには実質使えません。コンパイル型言語はコードのホットスワップが苦手で、インタプリタベースの対話的な言語は常にGCに依存しています。そこで考えたのが——音声処理を第一の目的として設計された汎用プログラミング言語を作ってみたらどうか、ということです。
既存言語への考察を踏まえて、mimiumの設計目標を決めました。SuperColliderの動的型システムは簡潔で柔軟な記法を与えてくれますが、ライブコーディングのためのランタイムシステムが複雑で、コードベースが巨大なため、Webやマイコンへの移植は容易ではありません。スタンフォードで開発されたChucKはサンプル精度のスケジューリングに優れますが、信号処理をパラメトリックに合成するのが難しく、ライブコーディングは単にインスタンスを差し替えるだけなので、更新のたびにディレイやリバーブの残響が途切れます。FaustはDSP記述に特化した言語で、非常に厳密に定義された意味論を持ち、多くのプラットフォームにトランスパイルできます。しかしDSP合成に特化しているのでライブパフォーマンスへの利用は難しく、音楽的な制御レベルでの作曲も簡単ではありません。また意味論は厳密でも、他のプログラミング言語と互換性のない独特な構文を持ち、学習コストが高いです。
こうした欠点を踏まえて、mimiumの設計目標を定めました。第一に、構文はJSやRustのような標準的なプログラミング言語に近い、馴染みのあるものにする。第二に、シグナルプロセッサやシグナルデータのための特別な型を持たず、すべてを普通の関数定義と関数適用で表現できるようにする。これはFaustの意味論からヒントを得て、フィードバックとディレイを最小限のプリミティブなステートフル演算として定義することで実現できます。メモリ管理については、ヒープ確保を可能な限り避けるのが目標です。
では言語設計の詳細に入ります。mimiumはλmmmと呼ぶ形式的な計算体系に基づいています。これはラムダ計算——皆さんお馴染みの、事実上すべての関数型プログラミング言語の理論的基盤——の拡張です。
関数型まつりの皆さんなら見慣れた形だと思います。ベースは値呼びの単純型付きラムダ計算で、mimium固有の部分は、フィードバックを表すfeed式と、delayがプリミティブとして組み込まれていることです。feed x.e は、式eの中で「1サンプル前のe自身の評価結果」をxとして参照できる、時間方向の不動点演算子のようなものです。具体的なコード例を見た方が分かりやすいので、次に進みましょう。
mimiumの動作を示す最もシンプルで分かりやすい例が、この1次ローパスフィルタ——実質的には積分器です。現在の入力と、自分自身の1つ前の出力をある比率で混ぜます。構文はRustによく似ていますが、selfキーワードの意味はまったく違います。関数の中でselfと書くと、その関数の1サンプル前の返り値が得られます。これが信号処理におけるフィードバック結線の表現方法です。つまりこの関数はステートフルで、サンプルごとに違う値を返します。ただし重要なのは、サンプル0では必ず0から始まり、決定的に計算されるので、同じ入力に対しては常に同じ列を生成するということです。
これをさらに拡張して、普通のイコライザに入っているようなバイクアッドフィルタを見てみましょう。実装の詳細は省きますが、注目してほしいのはselfの型が常にその関数の返り値の型と一致することです。つまりselfは多相的で、関数が何を返そうとそれに適応します。
selfがフィードバックのプリミティブであるように、delayも組み込み関数です。引数は3つ: 最大ディレイ長、入力信号、実際のディレイ長です。ここではselfと組み合わせて、シンプルなモノラルのフィードバックディレイを書いています。
エフェクトの例をいくつか見たので、オシレータを作ってみましょう。selfを使うと、サンプルレートで動く数値カウンタが作れます。それに剰余を適用すると鋸歯状のフェイザーになります。あとはスケールしてsin関数に通せば完成です。最後の関数ではパイプ演算子を使っています。左辺の値を右辺の関数に適用するだけの、関数型言語ではお馴染みの記法ですね。mimiumでは他の言語ならメソッドチェーンになるような信号処理の流れの記述に多用されます。
ここまでmimiumの信号処理言語としての基本機能を見てきました。次は、mimiumがラムダ計算に基づき、プロセッサを関数として表現しているからこそ可能になる、より強力な抽象化を見ていきます。ポイントは、信号処理の基本単位が関数なので、高階関数を使って複雑なプロセッサを合成できるということです。
高階関数によるプロセッサの合成は、実は音響合成では日常的に必要になる操作です。スーパーソウ・オシレータは、本質的には複数の鋸歯波を抽象化して複製したものです。mimiumのmapやfoldを使うと、これを非常に簡潔に表現できます。ここで新しい構文——バッククォートとドル記号——が出てきていますね。これがmimiumの型安全なマクロシステムで、ここから詳しく説明していきます。ちなみにmimiumにはforループのような制御構造はなくif式だけですが、関数定義と適用だけでかなり複雑なアルゴリズムを表現できます。
ここからが今日のメインテーマの1つです。mimiumのマクロシステムは、ラムダ計算の理論から出てきた多段階計算(Multi-stage Computation)という体系に基づいています。関数型言語の理論に馴染みのある方なら、MetaMLやTemplate Haskellのtyped quotationを思い浮かべてもらえれば近いです。
多段階計算とは何か。一言で言えば「コードを生成するコード」を言語の中で型安全に書くための体系です。Lispのquasiquoteとunquoteに型が付いたもの、と考えると分かりやすいと思います。バッククォートで式を評価せずコード値として扱い、ドル記号でコード値を別のコードの中に埋め込む。この型付きの体系はMetaMLに始まり、理論的には様相論理とのCurry–Howard対応として整理されています。Daviesのλ○は時相論理の「次」様相をコード型に対応させたもので、○Aは「次のステージで型Aを持つコード」と読めます。DaviesとPfenningのλ□はS4の□様相で、こちらは自由変数を含まない閉じたコードに対応します。mimiumはMetaML系、つまり開いたコードを許す体系の2ステージ構成です。
体系のコアを見てみましょう。λmmmの型にCode τを追加し、型判断にステージ番号nを付けます。quote規則は「1つ先のステージで型τを持つ式を引用すると、現在のステージでCode τ型の値になる」、splice規則はその逆です。変数はステージ付きで環境に入り、定義されたステージでしか参照できません。x:τ@nは「変数xがステージnで型τを持つ」という束縛を表します。ステージ0で計算した数値を実行時コードに持ち込みたいときはliftを使います。liftできるのは数値などのプリミティブ値だけです(本当は多相ですが今日は割愛)。重要なのは、quoteの中身が「1つ先のステージの式」としてきちんと型付けされることです。これにより、生成されるコードの型がマクロ展開の前に保証されます。特に何も考えずMetaMLと同じ規則をそのまま追加できるのがlambdammmの強みということになりそうですね。当たり前ですがマクロのコードは1回だけ実行されるのでマクロの中でfeedやdelayを使うことはありません。
mimiumでの実際のステージ構成はこうなっています。ステージ0がマクロ、つまりコンパイル時で、ステージ1が実行時の信号処理です。トップレベル定義に#stage(macro)アノテーションを付けるとステージ0に配置されます。マクロの本体はコンパイラに内蔵されたVMインタプリタで実行され、その結果のコード値がASTに埋め込まれてからコンパイルが続行します。つまりコンパイル時に任意の計算——ループも再帰もデータ構造の操作も——が実行できます。foo!(bar)という記法は$(foo(bar))の糖衣構文で、Rustのマクロ呼び出しに似た見た目になっています。
どうやってマクロを実行するのか。専用のマクロ展開インタプリタを作るのではなく、実行時と同じVMをそのまま使います。これを可能にしているのがCode Combinatorという古典的な実装手法の考え方です。型検査が終わった後、クォート式は「コード値=ASTを組み立てる操作」の呼び出し列に変換され、スプライスもこの変換の中で消えます。Lispのquasiquoteがconsやlistの呼び出しに展開されるのと同じで、その型付き版がMetaOCamlなどで使われています。変換後はもうクォートもスプライスも残っていない、ただのプログラムです。そしてこの「組み立て操作」を何として提供するかは自由なので、mimiumではVMの組み込み関数として提供しています。つまり、既存のレジスタマシンVMにAST操作のプリミティブを足すだけで、マクロは普通のVMプログラムとして走る。VM実装が1つで済むというのは、実装をシンプルに保つというmimiumの方針とも噛み合っています。
では、この体系の何がうれしいのか。最大のポイントは、型検査と型推論がマクロ展開の前に完了することです。マクロが展開された結果の式が謎の構文エラーや型エラーを起こす、というマクロにありがちな問題——Cのプリプロセッサや、実はFaustでもよく知られた苦しみです——がそもそも起きません。これはmimiumの強みの1つだと思っています。そしてマクロは本質的にコンパイル時に複雑な計算を走らせられるので——たとえばテキストのパーサをマクロとして実行すれば、mimiumの中にまったく別のDSLを埋め込むことができます。
実例がこれです。mimiumの上にuzulangというDSLを実装しました。TidalCyclesやStrudelで使われている、リズムパターンを表現するためのmini-notationと同じ記法です。mini関数の中に見えるのがそのmini-notationで、リズムパターンを記述するコンパクトな構文です。重要なのは、この記法のパーサがRustではなくmimium自身で書かれているということです。
ここまででmimiumでのDSPアルゴリズムの書き方とマクロシステムを見てきました。マクロシステムはかなりユニークですが、カバー範囲としてはFaustやCmajorのような言語とおおむね比較可能です。ここからは、そうした言語とmimiumを本当に分けているもう1つの機能——ライブコーディングについて話します。
mimiumのライブコーディングは、使う側から見るととてもシンプルです。VS Code拡張やCLIでファイルを実行している間に、ファイルを編集して保存する。それだけで音が自然に更新されます。 デモをやってみましょう。この機能の困ったところは、あまりに自然なので何が起きているのか、何がすごいのかが伝わりにくいことです。 このコードはループするメロディにフィードバックディレイがかかっています。フレーズのテンポを変えてみましょう。新しいメロディが入ってくる間も、ディレイの残響が途切れずに続いているのが聴こえますよね。フィードバック量も変えられます……では、ディレイへの入力をゼロにして、フィードバック率を1にしてみます。何も入力されていないのに音が鳴り続けるという、とても奇妙な状態です。これがmimiumのライブコーディングです。 実は設計の副産物のようなものだったのですが、他の言語と比べてもかなりユニークなものになりました。既存のランタイムを書き換えるのではなく、保存のたびに完全に新しい仮想マシンをゼロからコンパイルし、丸ごと入れ替えています。マシンを変更しないので、私はこれを「静的ライブコーディング」と呼んでいます——矛盾した名前なのは承知の上です。どう動いているのかは、この後の実装セクションで説明します。
ではこのライブコーディングが実際どう実装されているのか、ランタイムの内部に入っていきます。
この図はmimiumの仮想マシンランタイムのおおまかな全体像です。VMバックエンドでもWASMでもRustトランスパイラでも、だいたいこのモデルに従います。複雑に見えますが、基本的には標準的なレジスタマシンのアーキテクチャで、プログラムカウンタやコールスタックがあります。プログラムは静的変数の集合と、バイトコード命令列を持つ関数プロトタイプのリストで構成されます。実行時、ほとんどの値はヒープではなくスタックに置かれます。mimiumは値呼びのラムダ計算に基づいているので、関数呼び出しではほとんどの引数がコピーされます。ヒープに行くのはクロージャ、配列、文字列だけで、これらは参照カウントでGCされます。mimium固有の部分がState Storageと呼んでいるところで、次に詳しく説明します。
コード例で見たように、mimiumはディレイやフィードバックのようなステートフルな演算をただの関数として表現するので、ランタイムはこれを適切に扱う必要があります。多くの言語では、この種の状態のカプセル化はクラスやクロージャ、あるいはStateモナドで行われます。mimiumは別のモデルを使っています。私が相対移動ポインタモデルと呼んでいるものです。具体例を見ましょう。さっきのフィードバックディレイを、異なるディレイタイムとフィードバック量で3回適用しています。||>という記法はマクロによるパイプで、左辺のオペランドを右辺のアンダースコアに代入する部分適用です。この3つのfbdelay呼び出しは、それぞれ別のインスタンスとして扱われる必要があります。ではランタイムはそれをどう表現するのでしょうか?
これは低レベルのマシン、あるいはトランスパイル後のRustコードがどうなるかの擬似コードです。まず、このコード内のDSPの内部状態はすべて1本のフラットな配列として管理されます。その上に、配列のどこを読み書きするかを示すポインタが1つあります。fbdelayを見てみましょう。フィードバックを実行するとき、関数の先頭でポインタ位置から値を1つスタックに取り出します。次にディレイを処理するためにポインタを1つ進め、その領域をリングバッファとして解釈して更新します。最後にselfのために返り値を書き戻す必要があるので、ポインタを戻してデータを書き込みます。一方dsp関数側では、fbdelayを呼ぶたびに、fbdelayが使う状態の量だけポインタをオフセットしてから次を呼びます。こうすることで、fbdelayは常に同じコードを実行しながら、毎回異なるデータの上で動作します。重要なのは、ポインタの移動が絶対アドレスではなく相対オフセットで表現されていることです。それによって同じfbdelay関数が別々のインスタンスのように振る舞えます。これはトランスパイラにとっても本当に重要で、mimiumの関数をクラスに変換することなくターゲット言語の関数に直接マップできるので、実装がずっとシンプルに保てます。
そして、この状態データがすべてフラットな線形の配列に並んでいるという事実が、ライブコーディングにとっても決定的に重要になります。状態はdsp関数の呼び出し木の順序に従って並んでいるので、2つの呼び出し木を構造的に比較して共通部分を見つけられるのです。Web開発に馴染みのある方なら、ReactがVirtual DOMでやっていることに近い、と考えてください。使っているのは最長共通部分列——テキストや木構造データの比較の標準的なアルゴリズムです。この構造比較から、2つのバージョンのVMに共通する枝を抽出し、古いマシンから新しいマシンへ状態データをコピーするパッチ列を生成します。
具体的には、feed、delay、mem(memは1サンプルディレイです。リングバッファの読み書き位置を追跡する必要がないので別の演算になっています)というステートフルな演算をたどっていくと、呼び出し木の縮約版が導出でき、これがコンパイラに保存されます。呼び出し木の走査には無限ループに陥る危険がつきものですが、実はここでは起きません。高階関数や再帰関数に使われる関数はクロージャ——関数のインスタンスのようなもの——を生成し、クロージャの状態ストレージはグローバル領域ではなくインスタンス側に確保されるからです。なので木の走査は必ず停止します。
例を見てみましょう。オシレータの周波数を別のオシレータで変調するfmoscという関数があります。fmoscはoscに依存していて、oscは最初は単純なサイン波です。最初、dspは2つのfmoscと1つの単独のoscを使っています。そこでoscをphasorを2回使うように変更し、dspからoscを1つ削除します。このとき直感的には、fmoscの中のphasorですでに進んでいた位相の蓄積はそのまま引き継がれてほしい——リセットされてほしくない——ですよね。mimiumのライブコーディングはまさにそれを実現します。
この図はコード更新時に何が起きるかを示しています。ルートのdspの呼び出し木が上から下へ変化します。oscの定義が変わって新しいノードが追加されたので、新しいノードの状態はゼロ初期化され、それ以外はすべて引き継がれます。コンパイラがLCSアルゴリズムを走らせてパッチを生成します。各パッチはコピー元アドレス、コピー先アドレス、サイズからなる単純なmemcpy操作だと思ってください。VMが入れ替わる瞬間にこのパッチが適用されて、状態が移送されます。
このアプローチの良いところは、コードベースがどれだけ大きくても、パッチの生成はオーディオスレッドの外で行われることです。オーディオスレッドがブロックされるのはパッチを適用する間だけで、これは通常ごく短時間です。ライブコーディングでは全部を一度に書き換えるのではなく少しずつ変更していくのが普通だからです。つまり、音を途切れさせないための複雑なスレッド間同期がランタイムに不要なのです。たとえばArduinoやDaisyのようなOSなしのマイコン向けトランスパイラバックエンドを追加するとしても、基本ランタイムはOSレイヤの複雑な同期機構に依存しません。ランタイムの移植性が保たれます。メンタルモデルの面でも意味のある違いがあります。現在のランタイムの状態を把握して注意深くその場で変更を加えるのではなく、テキストに書かれたアルゴリズムそのものに集中すればいいのです。
最後にパフォーマンスの話をします。mimiumは速さだけを目指して設計されたわけではありませんが、極端に遅ければ深刻な問題です。では実際どのくらいの速さなのでしょうか?
このベンチマークでは4つのプラットフォームを比較しました。Rustトランスパイラを評価するためにRust互換のプラットフォームを選んでいます。libpd(Pure Dataのライブラリ版)のRustバインディング、FaustのRustバックエンド(デフォルトのコンパイルオプション)、ネイティブRustのDSPライブラリであるfundsp、そしてmimiumの3形態: VMインタプリタ、WASM、Rustトランスパイラです。タスクはサイン波オシレータによる加算合成で、左が10オシレータ、右が100オシレータ。縦軸は1024サンプルの処理時間で、低いほど良い。VMとWASMがかなり遅いので対数スケールです。ベンチマークのコードはテーブルルックアップではなく生のサイン関数を使っています。正直、Pure Dataのチューニングの良さには驚きました。ただしFaustやmimiumより粗い粒度で動作している点には注意してください。Rustトランスパイラは10オシレータではfundspに並びますが、100への拡張では10倍よりやや悪くスケールします。Faustも同じパターンで、バッファ単位計算とサンプル単位計算の違いによるものでしょう。Faustとの残りの差は相対移動ポインタモデルの理論的なオーバーヘッドと思われます。VMとWASMは非常に遅く、WASMは10倍、VMはさらに10倍遅い。理由の1つは、VMとWASMは64bit浮動小数点しか使えないのに対し、他のプラットフォームは32bitで動いていることです。とはいえVMバックエンドが無意味なわけではありません。基本的なリアルタイムDSPには十分な速度で、100オシレータをドロップアウトなしで鳴らせます。また、VMインタプリタはすべてのバックエンドでマクロ実行に使われています。mini-notationのパーサのような複雑なマクロコードには、それなりに速いVMが必要なのです。そして重要なことに、コンパイラは今のところほとんど最適化をしていません。定数畳み込み、インライン化——mimium側で改善の余地はまだ大きいです。
というわけで、mimiumは超高速ではないことが示されました。しかしそれが無意味だということにはなりません。音楽プログラミング言語の問題の1つは、評価のための共有された指標が存在しないことです。そして言語設計とは本質的にトレードオフの中での選択だと思っています。実行速度を上げるには一般により多くの静的解析が必要で、コンパイル時間が増える。表現の粒度を細かくしてパフォーマンスを調整できるようにすると、動的な変更が難しくなる——ほとんどのライブコーディング言語がMIDIレベルの信号でしか操作できないのを見てください。特定アーキテクチャへの特化は実行性能を上げますが移植を難しくする。そして全部を満たそうとすると、実装は巨大で複雑になります。実装のシンプルさは過小評価されている品質だと思います。つぎはぎの実装は曖昧な仕様につながり、コードサイズは新しいコントリビュータの障壁になります。ほとんどの音楽プログラミング言語が、原作者が存命だからこそ維持されているという事実——SuperColliderは例外ですが——を気にする人はあまりいません。原作者の死後も言語がメンテナンスされうるかどうかは深刻な問題だと感じています。mimiumはいくらかの実行性能をシンプルさと引き換えにしました。そしてパフォーマンスと動的なコード変更の間の緊張関係を、VMのホットスワップで解決した。これは本当にユニークな点だと思っています。
そろそろまとめに入ります。
この言語を作り始めて6年以上経ちました。冒頭で、DSLは特定のドメインを狙いながら汎用性も欲しがるという内在的な矛盾を抱えている、という話をしました。サブタイトルに「multi-purpose」と入れているのは、その哲学的な問いと格闘し続けているからです。プログラミング言語は、あらかじめ定義された問題を解くためだけに存在するのではなく、思考を拡張し、計算を楽しくし、探索の道具になるべきだと思っています。最近はmimiumでライブ演奏をしたり、Rustトランスパイラで自分のVSTプラグインを作ったりしています。固定されたゴールから始めた言語ではありませんが、少なくとも私にとっては本当に便利で面白い道具になりつつあります。mimiumは多段階計算のような現代のプログラミング言語理論を取り込んでいますが、まだ持ち込める理論はたくさんあります。そして、この言語を設計し始めたときには存在しなかったAIエージェントが、言語実装のための本当に強力な道具になりました。言語開発は基本的に、テストを書いて、通して、繰り返す——自律的なコーディングに完璧にはまる作業です。今日の話で興味が湧いたら、ぜひ自分の言語を設計してみてください。
mimiumでやりたいことはまだたくさんあります。Rustトランスパイラはまだ初期段階ですが使えるものになってきています。もっと広いプラットフォーム——特にマイコン——をターゲットにしたい。C++バックエンドもいいですね。今日は触れませんでしたが、mimiumにはRustに似たモジュールシステムがあり、まだかなり未成熟です。コードをオンラインで共有するパッケージマネージャもエコシステムの重要な部分になるでしょう。型システムの面では——今日mapやfoldを使いましたが、これらの関数にはジェネリック型が必要です。ジェネリクスのサポートはまだ限定的なので改善したい。複雑にしすぎたくはないですが、型クラスやインターフェースのようなものは追加する価値があるかもしれません。GUI統合やプラグインシステム、そしてライブ演奏の経験から、DSP制御を超えた作曲レベルの長い時間構造の扱いも、ライブラリとしてか言語機能としてか、再訪したいテーマです。
以上です。mimiumはラムダ計算に基づく関数型の音楽プログラミング言語です。UGenは関数として表現され、高階関数による合成ができます。多段階計算に基づく型安全なマクロシステムで、言語の中に別のDSLを埋め込めます。そして静的ライブコーディングによってコードを自然にホットスワップできます。ドキュメントはmimium.orgにあります。プロジェクトへのコントリビューションも大歓迎です。