(NIME2026のドラフトです)
概要
本稿では、筆者の開発している関数型音楽プログラミング言語mimiumで用いている、デジタル信号処理を対象のドメインに含めたライブコーディングシステムの設計を述べる。
音声信号処理をターゲットにしたプログラミング言語では、ソースコードを更新して評価し直すたびにディレイやフィルタなどの信号処理プロセッサの内部状態がリセットされることが一般的である。これはライブコーディングのように、実行中にソースコードを書き換えて演奏をするようなユースケースを阻む壁の一つである。
そこで筆者の開発する音楽プログラミング言語mimiumの機能を拡張し、信号処理で使われる内部状態の構造を変更前後で比較し、可能な限り変更前の状態を持ち越して新しいソースコードで評価できる仕組みを設計した。
このシステムの特徴は、ソースコード自体の変更増分を解析せずに、全てのソースコードを毎回再コンパイルし直し、コールツリーに基づく内部状態の構造の比較のみを行う点である。この方法を採用することで、既存のコンパイラやVMの定義の変更を最小限にしたままライブ評価を実現できる。
背景とモチベーション
音楽のためのプログラミングにおけるライブコーディングとは、音楽を生成するプログラムのソースコードをリアルタイムで書き換えながら演奏するパフォーマンスのスタイルである[@magnussonAlgorithmsScoresCoding2011]。
既存の信号処理をターゲットにした音楽プログラミング言語における問題の一つとして、コードの変更時に信号処理の内部状態がリセットされる問題がある。ディレイやフィルターは、内部状態(メモリ)への継続的な書き込みと読み込みを行うことで処理を実現しているが、その内部状態のインスタンスはコードのコンパイル後、信号処理を実際に始める前に0埋めで初期化されることが一般的である。
Max(MSP)[@Max]やPureData[@puckettePureData1997]、SuperCollider[@McCartney2002]のJITLibにおける信号処理のように、信号処理のインスタンスのグラフ構成自体を実行中に変更できるような仕組みの場合、内部状態はキープされる。TidaiCycles[@McLean2014]やSonic Pi[@Aaron2013]のようなSuperColliderのクライアントとして実装される言語も同様である一方、信号処理を使った表現の幅はSuperColliderのプリミティブとして用意されたUnit Generatorの組み合わせに留まることになる。
Faust[@Orlarey2009]やMaxのGen、のように、サンプル単位レベルでの信号処理の記述ができるプログラミング言語の場合は、コードを一度低レベルな命令(FaustであればLLVM IRなど)に変換し、そのコードをインスタンス化してから実行するために、インスタンス化のタイミングで毎回内部状態はリセットされる。
同様に例えばChucK[@Wang2015]はShredという単位で信号処理インスタンスを実行中に追加、削除、更新ができるが、1つのShredが更新されるごとに内部状態はリセットされる。そのため、複数のShredが実行されていればどれか1つのShredを更新するたびに無音が挟まるようなことはないものの、Shredの中でディレイやリバーブを使用していた場合、そのディレイやリバーブのテールは更新時に途切れてしまう。
こうした特徴をまとめると、音楽プログラミング言語の設計には記述できる信号処理の最小単位を小さくしていくほど、コードの動的変更に対応することが難しくなるトレードオフがあるといえる。
こうした課題に対し、Reachは関数型のUnit Generatorを組み合わせて信号処理を記述する言語で、ソースコードの変更差分を解析して信号処理の内部状態を可能な限り保持する仕組み:Incremental Functional Reactive Programming(IcFRP)を提案している[@reach_incremental_2013]。この仕組みは、SuperColliderのJITLibのようなシステムと比べるとユーザーが現在の信号処理インスタンスに対して削除や追加などの命令を行うのではなく、常にその時のソースコードに望む信号処理を書けば必要な状態の更新はランタイム側が自動で担ってくれるという点で、ユーザーの演奏中の思考モデルが大きく異なると言える。
ただ、Reachによる実装としては、ソースコードの単なるテキスト差分の解析では、複数の変更のパターンの可能性を絞り込めないため、テキストエディタEmacsの拡張として操作の履歴を取得することで実装しているため、実装は複雑なものと言える。
本稿では、筆者が開発してきた関数型音楽プログラミング言語mimiumに、IcFRPの考え方を応用したライブコーディングシステムを提案する。
以下、本論文はmimiumのこれまでの言語設計の簡単な説明と、導入される2種類の機能拡張について順番に説明する。
その後、本ライブコーディングシステムの他のシステムと比較した特徴および問題点を議論する。
mimium and lambda-mmm
mimiumは、Rustに近いシンタックスを持った関数型の音楽信号処理をターゲットドメインにしたプログラミング言語である[@matsuura2021]。現在の内部実行モデルとして、値呼び単純型付きラムダ計算を拡張し、最小限の内部状態を持つプリミティブ操作:ディレイとフィードバックを加えたLambda-mmm[@matsuura_lambda-mmm_2024]という計算体系を持っている。
mimiumはコードを専用のVMバイトコードへコンパイルし実行する。実行モデルは、一般的なレジスタマシンの命令セットに、内部状態操作用の操作が加わったものとなる。ディレイやフィードバックで用いられる内部状態は、状態ストレージという1次元の配列領域と単一の読み出し位置ポインタを組み合わせたデータ領域に保存される。
コンパイラは、状態ストレージの読み出し位置ポインタを相対的に前後させる命令を適切に出力することで、VM実行時にはストレージの一部分をディレイ用のリングバッファとして解釈しデータを読み書きする。
過去のmimiumでは高階関数などを使うことによって任意の数のフィルタバンクのような、パラメトリックなプロセッサを生成することもできたが、こうしたプロセッサは状態ストレージのレイアウトとメモリサイズを決定できなかった。そのため、グローバルな関数の呼び出しとクロージャ(実行時に高階関数から生成される関数)の呼び出しは区別され、クロージャのインスタンスに個別の状態ストレージを生成し、クロージャ呼び出し時に使用する状態ストレージそのものを切り替えることで対応していた。
今回提案するライブコーディング機能は、2つの機能によって実現される。
1つは、状態ストレージのレイアウトをコンパイル時に可能な限り確定させるために、多段階計算という型安全なマクロの体系を言語に導入することである。
もう1つは、生成されたプログラム同士の状態ストレージのレイアウトを比較することで、可能な限り前の内部状態を引き継いだ新しい状態ストレージを計算する解析プログラムの導入である。
この2機能を組み合わせることで、mimiumは単純にソースコードを毎回最初からコンパイルし、前のプログラムとの比較を行い、新しい内部状態ストレージを含むVMを生成しインスタンスを入れ替えるることで、エディタとの連携なしにライブコーディング機能を実現する。
多段階計算
多段階計算は、型付きラムダ計算に対して、計算のステージを複数段階に分割する明示的なシンタックスを導入するものである。Lisp系言語のquote/splice機能[@lisp]のように、部分的に計算したコード片を埋め込むようなものを想定しているが、不正な値が埋め込まれないことを型システムとして保証することが特徴である。
実用的な例では、Scala 3でのマクロや、関数型組版処理エンジンSaTysFi[@suwa2024]のように、言語内DSLを型安全にライブラリとして実装することを想定しているものがある。
シンタックス
図nにmimiumの内部表現Lambda-mmmに多段階計算の体系を加えた新しい内部表現 (Multi-stage version of )のシンタックスを定義する。
<!-- line:73 --> <!-- line:74 --> <!-- line:75 --> $$<!-- line:76 --> <!-- line:77 --> ### シンタックスシュガー<!-- line:78 --> <!-- line:79 --> mimiumでは、多段階計算の体系を直感的に扱えるように2つのシンタックスシュガーを導入している。<!-- line:80 --> <!-- line:81 --> 1つは、ステージ0でコード辺を返す関数`mymacro`を実行した結果をスプライスでステージ1に埋め込む操作、`$(mymacro(arg))`を、`mymacro!(arg)`と書けるようにするものである。<!-- line:82 --> <!-- line:83 --> もう1つは、変数のスコープを維持したままだとスプライスとクオートの括弧がネストされてしまう問題を回避するために、グローバルスコープに限定し、#stage(macro)/#stage(main)と書くことで、スコープを維持したままその行以下は指定したステージになるというものである。<!-- line:84 --> <!-- line:85 --> ### 多段階計算によるメタ操作の実例<!-- line:86 --> <!-- line:87 --> ```rust fn cascade(n,gen){<!-- line:89 --> let g = gen()<!-- line:90 --> if (n>0){<!-- line:91 --> let c = cascade(n - 1.0 ,gen)<!-- line:92 --> let multiplier = 1.0-(1.0/(n*2.0))<!-- line:93 --> |rate| { rate + g(rate/2.0)* 0.5 * rate * multiplier |> c } <!-- line:94 --> }else{<!-- line:95 --> |rate| g(rate) <!-- line:96 --> }<!-- line:97 --> }<!-- line:98 --> fn osc(){<!-- line:99 --> ...<!-- line:100 --> }<!-- line:101 --> let myosc = cascade(20, | | osc);<!-- line:102 --> fn dsp(){<!-- line:103 --> let f = 200<!-- line:104 --> let r = f |> myosc<!-- line:105 --> (r,r)<!-- line:106 --> }<!-- line:107 --> ``` <!-- line:109 --> ```rust #stage(macro)<!-- line:111 --> fn cascade(n,gen){<!-- line:112 --> if (n>0.0){<!-- line:113 --> let multiplier = 1.0-(1.0/(n*3)) |> lift_f<!-- line:114 --> `{|rate| rate + ($gen)(rate/3)* 0.5 * rate* $multiplier <!-- line:115 --> |> $cascade(n - 1.0 ,gen) }<!-- line:116 --> }else{<!-- line:117 --> `{|rate| ($gen)(rate)}<!-- line:118 --> }<!-- line:119 --> }<!-- line:120 --> #stage(main)<!-- line:121 --> fn osc(){<!-- line:122 --> ...<!-- line:123 --> }<!-- line:124 --> fn dsp(){<!-- line:125 --> let f = 200<!-- line:126 --> let r = f |> cascade!(20,`osc)<!-- line:127 --> (r,r)<!-- line:128 --> }<!-- line:129 --> ``` ## コールツリーの解析<!-- line:131 --> <!-- line:132 --> - クロージャ<!-- line:133 --> - 関数定義内でグローバルでもローカルでもない変数を参照している場合。<!-- line:134 --> <!-- line:135 --> クロージャ(ほとんどの場合、高階関数)に関わる内部状態は、クロージャインスタンス上に保持される。<!-- line:136 --> <!-- line:137 --> 内部状態を参照するのは、ディレイ、Feed、非クロージャ関数呼び出しのいずれか。<!-- line:138 --> <!-- line:139 --> エントリポイント`dsp`からの非クロージャ関数呼び出しを辿っていけば静的に使用する内部状態の木構造が導出できる。<!-- line:140 --> <!-- line:141 --> 再帰関数を用いて複雑な信号処理を実現する場合でも、多段階計算を使用してコードを記述した場合は、実行時にクロージャを生成することなく静的な関数呼び出し<!-- line:142 --> <!-- line:143 --> ## 状態構造ツリー同士の比較<!-- line:144 --> <!-- line:145 --> ソースコードをなるべく頻繁に編集するのであれば、内部状態の構造の変化は木の要素のうち1箇所の削除、追加、置き換えである可能性が高い。<!-- line:146 --> <!-- line:147 --> まず先頭、末尾からのリニアスキャンで比較<!-- line:148 --> <!-- line:149 --> まだ完了していなければ、最長共通部分列の検出を行う。<!-- line:150 --> <!-- line:151 --> 再帰的なマッチの仕方 パーシャルマッチみたいなものが起きうる<!-- line:152 --> <!-- line:153 --> 内部状態構造を比較する木は、あくまで線形メモリ上の参照範囲を保持しており、データそのものを木構造として保持しているわけではない。<!-- line:154 --> <!-- line:155 --> まず、新しい内部状態木構造から必要なメモリサイズを算出し、メモリをアロケートする。<!-- line:156 --> <!-- line:157 --> その後、木構造の比較を行い、必要な差分変更処理のパッチを作成する。<!-- line:158 --> <!-- line:159 --> <!-- line:160 --> <!-- line:161 --> (ここまではオーディオスレッドをブロックせずに実行可能)<!-- line:162 --> 古い内部メモリと木構造にパッチを適用し、新しいメモリへ必要なデータをコピーする。<!-- line:163 --> <!-- line:164 --> 信号処理インスタンスを、新たに確保したメモリと共にオーディオバッファ処理の間に入れ替える。<!-- line:165 --> <!-- line:166 --> ### サンプルコード<!-- line:167 --> <!-- line:168 --> ```diff fn phasor(freq){<!-- line:170 --> (self+(1/freq))%1.0<!-- line:171 --> }<!-- line:172 --> fn osc(freq){<!-- line:173 --> - phasor(freq)* 2 * PI |> sin<!-- line:174 --> + phasor(freq+(phasor(freq/10))) * 2 * PI |> sin<!-- line:175 --> }<!-- line:176 --> <!-- line:177 --> ``` <!-- line:179 --> <!-- line:180 --> ## 議論<!-- line:181 --> <!-- line:182 --> <!-- line:183 --> ### 他の言語とのコンパイル時計算のパラダイム比較<!-- line:184 --> <!-- line:185 --> | | Faust | Kronos | mimium |<!-- line:186 --> | ---------------- | ------- | ------- | ------------------ |<!-- line:187 --> | パラメトリックな信号ルーティング | 項書換えマクロ | 型レベル計算 | ステージ0の計算/グローバル環境評価 |<!-- line:188 --> | 実際の信号処理 | BDA | 値レベルの評価 | ステージ1の計算 |<!-- line:189 --> <!-- line:190 --> <!-- line:191 --> 多段階計算の不足している部分<!-- line:192 --> <!-- line:193 --> マクロとしては、型安全な代わりに、型システムの範囲を超えたメタ操作ができない。型システムが充実しない限り、Kronosのようなタプルの要素数をパラメトリックに扱うようなことはできない。代わりに配列を操作する。これはこれで体系がシンプルでいいかも。<!-- line:194 --> <!-- line:195 --> 現状、ツリーウォーク型のインタプリタでステージ0を評価し、VMのバイトコードに変換してからステージ1を評価している。これは、リアルタイム実行パフォーマンスの維持しつつ多段階計算の体系を導入するための苦肉の策である。せっかくマクロと実際の計算を同じ意味論で実行できるのに、結局2つの処理系を実装している。<!-- line:196 --> <!-- line:197 --> <!-- line:198 --> <!-- line:199 --> <!-- line:200 --> ### ライブ状態更新のエッジケース<!-- line:201 --> <!-- line:202 --> 差分処理を実行している間に内部状態が更新されてはいけない。なので、新しいソースコードのパースや内部状態構造導出、VMコード生成、木の比較までは非同期で行えるが、コピー中はオーディオ処理全てを一度中断しなくてはならない。<!-- line:203 --> <!-- line:204 --> 構造が大きくなった時にドロップアウトしないか。木のサイズでざっくりベンチを取りたい。バッファサイズの参考にできるはず<!-- line:205 --> <!-- line:206 --> 最長共通部分列の仕様上、`osc1()+osc2()`から`osc2()+osc1()`への変更のような、明らかに問題のない入れ替えでもどちらか片方しか引き継がれない。また、どちらが引き継がれるかはLCSのバックトラックの戦略に依存する。ツリーのノードの子の種類として、順序の関係ない集合、ある集合とを区別するようにすれば実現できるかも。<!-- line:207 --> 関連して、引き継いではいけないはずのデータを引き継ぐ可能性がある。<!-- line:208 --> <!-- line:209 --> ```rust fn phasor1(freq){ //0 ~ samplerate/freq<!-- line:211 --> (self+1.0)% (samplerate/freq)<!-- line:212 --> }<!-- line:213 --> fn osc1(freq){<!-- line:214 --> phasor1(freq)*freq/samplerate |> sin<!-- line:215 --> }<!-- line:216 --> fn myfreq(){<!-- line:217 --> 1000 + osc1(1.0)*100 //myfreqはFncall[FnCall[Feed]]を持つ<!-- line:218 --> }<!-- line:219 --> fn phasor2(freq){ //0 ~ 1<!-- line:220 --> (self+freq/samplerate)% 1.0<!-- line:221 --> }<!-- line:222 --> fn osc2(freq){<!-- line:223 --> phasor2(freq) |> sin<!-- line:224 --> }<!-- line:225 --> fn myamp(){<!-- line:226 --> (osc2(1.0)+1.0) / 2 //myampもFncall[FnCall[Feed]]<!-- line:227 --> }<!-- line:228 --> <!-- line:229 --> //変更前<!-- line:230 --> fn dsp(){ //dspはFncall[Fncall[FnCall[Feed]],Fncall[Feed]]<!-- line:231 --> let f = myfreq()<!-- line:232 --> osc2(f)<!-- line:233 --> }<!-- line:234 --> //変更後<!-- line:235 --> fn dsp(){//dspはFncall[Fncall[FnCall[Feed]],Fncall[Feed]]で変化なし<!-- line:236 --> myamp() * osc2(1000) <!-- line:237 --> }<!-- line:238 --> ``` <!-- line:240 --> 上のサンプルでは、はじめlfoを使って周波数をモジュレーションしている状態から、周波数は固定にして音量をモジュレーションする処理へと切り替えた例である。myfreq()とmyamp()はそれぞれどちらもosc関数を1度だけ呼び出すため、dsp関数の内部状態ツリーの構成は共通しており、再コンパイル時にデータが引き継がれる。<!-- line:241 --> この時、myampにはmyfreqの最後の位相が引き継がれることになるが、今回実装しているphasor1はselfに保存される値が0~samplerate/freq、例えば1000Hzなら0~48の値のレンジを取り、これがmyampの中で使われているphasor2のselfのその値の本来のレンジは0~1であるべきにも関わらず引き継がれてしまう。<!-- line:242 --> <!-- line:243 --> ただ、結局phasor2を実行したときには0~1のレンジに丸まるので大きな問題にはならない。ある関数がある範囲に収まることが保証されているということは、仮にそこで使われているselfに不正な値が差し込まれたとしても、その関数が計算し終わったときには元の範囲に収まる可能性が高いからだ。<!-- line:244 --> <!-- line:245 --> <!-- line:246 --> <!-- line:247 --> ツリー構築の際に、FnCallはヒントとして関数のラベルを受け取るような変更が考えられる。無名関数はヒントなしで頑張る。<!-- line:248 --> <!-- line:249 --> <!-- line:250 --> ## 将来的な展望<!-- line:251 --> <!-- line:252 --> <!-- line:253 --> 原理的にはFaustでも実現できるはず。<!-- line:254 --> <!-- line:255 --> ディレイ、Feed以外に、外部定義の関数呼び出しにもこの仕組みを応用できるか?LuaのUserData的な仕組み。<!-- line:256 --> Faustにおけるrwtableのように、単にCell的な仕組みを用意すればDelayやMemもこの上に乗っかる形で全部カバーできるはず<!-- line:257 --> <!-- line:258 --> rwtable(read_index:float,write_index:float,input:float,size:const-float)<!-- line:259 -->