Files
quartz-research-note/content/関数型信号処理プログラミング言語のソース変更時に内部状態を差分保持するシステム.md
松浦 知也 Matsuura Tomoya 2c6d2a52da
All checks were successful
Build / build (push) Successful in 11m25s
[obsidian] vault backup: 2025-10-08 12:53:10
2025-10-08 12:53:10 +09:00

8.6 KiB
Raw Blame History

date
date
2025-09-25 11:42

#paper

概要

本稿は、筆者の開発している関数型音楽プログラミング言語mimiumにおける、ソースコード更新の際に実行中の信号処理の内部状態を可能な限り保持する仕組みのデザインの提案である。

多くの音声信号処理をターゲットにしたプログラミング言語では、ソースコードを更新して評価し直すたびにディレイやフィルタなどの信号処理プロセッサの内部状態がリセットされる。これはライブコーディングのように、実行中にソースコードを書き換えて演奏をするようなユースケースを阻む壁の一つである。

そこで筆者の開発する音楽プログラミング言語mimiumの機能を拡張し、信号処理で使われる内部状態の構造を変更前後で比較し、可能な限り変更前の状態を持ち越して新しいソースコードで評価できる仕組みを設計した。

このシステムの特徴は、ソースコード自体の変更増分は解析せず、全てのソースコードを毎回再コンパイルし直し、コールツリーに基づく内部状態の構造の比較のみを行う点である。この方法を採用することで、既存のコンパイラやVMの定義の変更を最小限にしたままライブ評価を実現できる。

背景

既存の信号処理をターゲットにした音楽プログラミング言語における問題の一つとして、コードの変更時に信号処理の内部状態がリセットされる問題がある。ディレイやフィルターは、内部状態メモリへの継続的な書き込みと読み込みを行うことで処理を実現しているが、その内部状態のインスタンスはコードのコンパイル後、信号処理を実際に始める前に0埋めで初期化されることが一般的である。

MaxMSPやPureDataにおける信号処理のように、信号処理のインスタンスのグラフ接続自体を実行中に変更できるような仕組みの場合、内部状態は変更されずグラフ接続の変更処理自体が間に合えばオーディオが途切れることはない。

FaustやMaxのGenのように、サンプル単位レベルでの信号処理の記述ができるプログラミング言語の場合は、コードを一度低レベルな命令FaustであればLLVM IRなどに変換し、そのコードをインスタンス化してから実行するために、インスタンス化のタイミングで毎回内部状態はリセットされる。

ChucKのようなサンプル単位の制御処理を実現している言語も同様である。ChucKはShredという単位で信号処理インスタンスを実行中に追加、削除、更新ができるが、1つのShredが更新されるごとに内部状態はリセットされる。そのため、複数のShredが実行されていればどれか1つのShredを更新するたびに無音が挟まるようなことはないものの、Shredの中でディレイやリバーブを使用していた場合、そのディレイやリバーブのテールは更新時に途切れてしまう問題がある。

一般的に、記述できる信号処理の最小単位を小さくしていくほど、コードの動的変更に対応することが難しくなるトレードオフがある(要引用)。

ユースケースと先行例

Incremental Functional Reactive Programmingがある。

これはソースコードの増分比較に基づいている。同じ差分でも、複数の変更のパターンがあり得るので、単なるテキスト比較ではなくEmacsの拡張として操作の履歴を取得している。

プリミティブを小さくする&&ソースコードの変更→評価の間隔が短くても済むような仕組みを作れば良い。

lambda-mmm

内部状態はDelayかMem、Feedのいずれかに還元される。 Memは1サンプルのみのディレイであるため、最終的な計算結果としてDelayに与える遅延時間を1としたものと同一に見做せるが、実行モデル的に1サンプルのみのディレイはリングバッファの読み書きインデックスの追跡が必要ないため、区別しておく方が効率的に実行できる。

詳細は(Matsuura,2024)を見よ。

コールツリーの解析

  • クロージャ
    • 関数定義内でグローバルでもローカルでもない変数を参照している場合。

クロージャ(ほとんどの場合、高階関数)に関わる内部状態は、クロージャインスタンス上に保持される。

内部状態を参照するのは、ディレイ、Feed、非クロージャ関数呼び出しのいずれか。

エントリポイントdspからの非クロージャ関数呼び出しを辿っていけば静的に使用する内部状態の木構造が導出できる。

再帰関数を用いて複雑な信号処理を実現する場合でも、多段階計算を使用してコードを記述した場合は、実行時にクロージャを生成することなく静的な関数呼び出し

状態構造ツリー同士の比較

ソースコードをなるべく頻繁に編集するのであれば、内部状態の構造の変化は木の要素のうち1箇所の削除、追加、置き換えである可能性が高い。

まず先頭、末尾からのリニアスキャンで比較

まだ完了していなければ、最長共通部分列の検出を行う。

内部状態構造を比較する木は、あくまで線形メモリ上の参照範囲を保持しており、データそのものを木構造として保持しているわけではない。

まず、新しい内部状態木構造から必要なメモリサイズを算出し、メモリをアロケートする。

その後、木構造の比較を行い、必要な差分変更処理のパッチを作成する。

(ここまではオーディオスレッドをブロックせずに実行可能) 古い内部メモリと木構造にパッチを適用し、新しいメモリへ必要なデータをコピーする。

信号処理インスタンスを、新たに確保したメモリと共にオーディオバッファ処理の間に入れ替える。

問題点

差分処理を実行している間に内部状態が更新されてはいけない。なので、新しいソースコードのパースや内部状態構造導出、VMコード生成、木の比較までは非同期で行えるが、コピー中はオーディオ処理全てを一度中断しなくてはならない。

構造が大きくなった時にドロップアウトしないか。木のサイズでざっくりベンチを取りたい。バッファサイズの参考にできるはず

たまたま入れ替えた時に、引き継いではいけないはずのデータを引き継ぐ可能性がある。

fn osc(freq){
 let phase = samplerate/freq
  (self+phase)%1.0 |> sin
}
fn myfreq(){
   1000 + osc(1.0)*100 //oscは1Feedを持つ
}
fn myamp(){
   (osc(1.0)+1.0) / 2
}
//変更前
fn dsp(){
 osc(myfreq())
}
//変更後
fn dsp(){
 osc(1000)*myamp()
}

上のサンプルでは、はじめlfoを使って周波数をモジュレーションしている状態から、周波数は固定にして音量をモジュレーションする処理へと切り替えた例である。myfreq()とmyamp()はそれぞれどちらもosc関数を1度だけ呼び出すため、dsp関数の内部状態ツリーの構成は共通しており、再コンパイル時にデータが引き継がれる。 この時、myampにはmyfreqの最後の位相が引き継がれることになるが、これは特に望ましいというわけではないどうでもよさそうではあるけど

myfreqとmyampをそれぞれselfを使って個別に実装していた場合、値の範囲がおかしくなる可能性がある。

ツリー構築の際に、関数ラベルをつけるようにすればいいが、ラベルのネーミング方法に一貫性がないとダメ

親の関数名+child1,2,3...みたいにすれば多分大丈夫。

将来的な展望

原理的にはFaustでも実現できるはず。

ディレイ、Feed以外に、外部定義の関数呼び出しにもこの仕組みを応用できるかLuaのUserData的な仕組み。 Faustにおけるrwtableのように、単にCell的な仕組みを用意すればDelayやMemもこの上に乗っかる形で全部カバーできるはず

rwtable(read_index:float,write_index:float,input:float,size:const-float)