前回までのところで、シンセサイザを作り(SynthDef、Synth)、そのラッパクラス(Synth)を使いクライアント(sclang)からサーバ(scsynth)を操作できるというところまでたどり着いた。
SynthDefのコンストラクタの2つめの引数ugenGraphFuncで渡される関数オブジェクトの引数が、すなわちそのシンセサイザのコントローラとなり、Synthクラスのsetメソッドを使って値をセットすることが出来る。
とりあえず演奏すべきシンセサイザを作るところまでは来たので、次はそれを演奏させたい。手動で値をセットするのも良いのだけど、ある程度は自動演奏させたい。
SuperColliderには演奏させるための仕組みが色々と用意されている。今回ピックアップしたのはRoutineクラスだ。流れ的には、必要なコントロールパラメータを設定し、必要なだけスリープする(次のタイミングを待つ)、その繰り返しが単純明快なので、サーバ側でのUGenの時のようにクライアント側のSuperCollider探求のとっかかりとして最適なのではないかと思ったのだ。
話の舞台はサーバからクライアントに移る。
Routineクラス
Routineクラスは他のコンピュータ言語でいうとThreadというのがわかりやすいだろうか。Routineクラスも左図のとおりThreadクラスの派生クラスになっている。面白いのはThreadクラスがStreamクラスの派生クラスであるあたりだろうか。このStreamのニュアンスが他言語と異なるのだけど、それは今回の範囲からは外れている。
RoutineクラスはThreadの派生クラスであることでも分かるように、メインスレッドと平行して実行する機能を提供している。何を実行するかというのは、今までにも何回も出てきたようにSuperColliderは関数自体がオブジェクト({}のブロックは関数でオブジェクト)なため、それをコンストラクタで渡せばそれを実行する。
*new { arg func, stackSize=64; ^super.new.init(func, stackSize) }
Playing
もう少し詳しく見るために、簡単にRoutineを使って演奏をするコードを書いてみた。
( var synth,routine; synth = Synth("default"); routine = Routine({ 12.do {|i| synth.set(\freq, (60 + i).midicps); 1.wait; }}); routine.play; )
この例ではRoutineが実行すべき関数オブジェクトは{12.doではじまるブロックだ。12.doの12回繰り返しの中で、C4から半音づつB4まで音程を上げていく(サーバは事前にbootしているものと想定している)。
“default”はSuperColliderがあらかじめ用意してくれているSynthDefで、それを使用してシンセサイザノードを作成している。とりあえずシンセサイザではなく、音のロジックを組むような用途にはサッとコーディングが出来て便利だ。
演奏を開始するのがplayメソッドだ。Routineにはrunというメソッドもあるのだけど、playというだけあって細かいところでrunとは異なっている。playメソッドを追ってみよう。playメソッドは基底クラスのStreamクラスで実装してある。
play { arg clock, quant; clock = clock ? TempoClock.default; clock.play(this, quant.asQuant); }
playメソッドは引数にclockとquantをとる。いま引数無しで呼んでいるのでどちらもnilになっている。clockはnilだった場合はTempoClock.defaultとなる。TempoClockというクラス名を見てもわかるように、いわゆるメトロノーム的なクロックだ。defaultというのはTempoClockのクラス変数で、TempoClockクラスが読み込まれたときにTempoClockクラスのインスタンスで初期化されている。
TempoClockはClockクラスの派生クラスで、Clockクラスは指定した時間にトリガーを発行を行うクラス群だ。目覚まし時計のようなものとイメージしても良いだろう。TempoClockはいわゆるテンポ的な時間単位でトリガの発行を指定するクラスだ。
Streamクラスのplayメソッドは、実際には(この場合は)TempoClockクラスのplayメソッドが呼ばれる。
play { arg task, quant = 1; this.schedAbs(quant.nextTimeOnGrid(this), task) }
そして続いてTempoClockクラスのschedAbsメソッドが呼ばれる。
schedAbs { arg beat, item; _TempoClock_SchedAbs ^this.primitiveFailed }
ここまで来たところで、内部関数が呼ばれる。以前にも出てきたのだけどアンダースコア(_)で始まるものはSuperColliderではプリミティブと呼ばれてネイティブコードで書かれた内部関数コールとなる。
_TempoClock_SchedAbsは実際にはPyrSched.cppにあるprTempoClock_SchedAbsが呼ばれる。この関数はClockオブジェクトの待ち行列にタスク(この場合は引数で渡したRoutineオブジェクト)を、起動のトリガーをかけてもらう時間を指定して追加する
(モーニングコールの予約をするようなものだ)。
Awaking
さて、playメソッドもここで行き止まりだ。つまり、これ以上のことはなにもしていない。最終的にはTempoClockに指定時間に起動トリガーを掛けてもらう予約をしただけだ。
では、いつ誰がどのようにして登録したルーチンを呼び出してくれるのだろうか。
実はTempoClockはインスタンスが生成されたときにスレッドを同時に生成して、そのインスタンス用のスレッドが起動しているのだ。TempoClock.defaultはクラス初期化時に生成されるので、常に一つ以上はClockインスタンスのスレッドが存在している。その実装は同じくPyrSched.cppのTempoClock::Runにある。大まかな流れはこんな感じだ。
まずキューに何もない状態では同期オブジェクト(pthreadの条件変数)が変化するまでサスペンド状態になっている。キューにタスクが追加されたら同期オブジェクトが変化し、次の状態へと移行する。
次の状態では次の一番近いトリガーの時間を超えてる場合はそのトリガーを発行、そうでない場合はそのときまで、もしくは同期オブジェクトが変化するまでサスペンドする。
同期オブジェクトはその他TempoClockオブジェクトを終了させたいときにも変化する。
さて、というわけで、指定した時間になるとトリガーがかかるわけだ。そのときの流れを追っていこう。まず、登録されたオブジェクトにawakeメッセージが投げられる(awakeメソッドが呼ばれる)。Routineクラスのでのawakeメソッドの実装はこうなっている。
awake { arg inBeats, inSeconds, inClock; var temp = inBeats; // prevent optimization ^this.next(inBeats) }
そしてnextメソッド。
next { arg inval; _RoutineResume ^this.primitiveFailed }
またもやプリミティブ(内部関数コール)だ。_RoutineResumeの実体はPyrPrimitive.cppのprRoutineResumeで、この関数はヴァーチャルマシーンの実行スレッドをRoutineオブジェクトのコンテキストに切り替えた後に、最終的にprStartメソッドを呼び出す。それまではスレッドのコンテキストはClockのコンテキストだったのが、ここでRoutineのコンテキストに切り替わるのだ。RoutineクラスのprStart。
prStart { arg inval; func.value(inval); // if the user's function returns then always yield nil nil.alwaysYield; }
funcはRoutineのコンストラクタで渡した関数オブジェクトだ。valueはその関数オブジェクトを実行し、その値を返す。やっとここで演奏のための関数オブジェクトが実行されることになる。
Return Path
ここでサンプルに書いたコードをもう一度見てみると、毎回半音上げる度に1.waitという文を実行している。なにか1単位待ってるのだなというのは容易に想像できるのだけど、さてこれはどういう処理になってるのだろう。
waitメソッドはSimpleNumberクラスで実装されている。
wait { ^this.yield }
yieldというのはthreadプログラミングではなじみのある言葉で、スレッドの実行をそこで中断する。yieldメソッドはObjectクラスで実装されている。
yield { _RoutineYield ^this.primitiveFailed }
そうしてプリミティブだ。この実体はPyrPrimitive.cppにあるprRoutineYieldだ。このメソッドはObjectクラスに実装されているのでどこからでも呼べるわけなのだけど、RoutineとあるようにRoutineのコンテキスト内でないところで呼ばれるとエラーを返す。
この内部関数は以前のClockコンテキストにスレッドを変更するだけだ。しかし、面白い処理を一つ行っている。実はこの関数はメソッドのレシーバ、つまり呼び出す元となったオブジェクトを返すのだ。いま1.waitだったのだから、戻り値は1ということになる。どうしてそんなことを行っているのだろうか。
yieldの戻り値は、さてどこに返るのかを思い出してみたい。yield(wait)の時点で既にスレッドのコンテキストは元のものに変わっている。では、そもそもどこでRoutineのコンテキストに変わったかというと、nextの時点なわけだ。つまり元に戻ってyieldの戻り値はnextの戻り値になる。
そうして、追っていった順を逆に辿り、nextの戻り値はawakeの戻り値になるのだ。awakeは内部のTempoClockクラス(C++実装)のRunメソッドから呼ばれている。awakeを呼び出す周辺だけを抜き出してみよう。
runAwakeMessage(g); long err = slotDoubleVal(&g->result, &delta); if (!err) { // add delta time and reschedule double beats = mBeats + delta; Add(beats, &task); }
awakeを呼び出し、戻り値を取得する。もし戻り値が存在するなら、今の時間にその時間を足して、その時間を次のトリガーを掛ける時間として予約する。そういう処理を行っている。こうして戻り値が次の時間までのΔtとなってるわけだ。
この実装を見てみると、prStartメソッドでなぜ最後まで到達したときにnilをレシーバとしてyield(alwaysYield)を呼んでるかという理由が分かるのだ(戻り値がnilだった場合には(yieldの戻り値はレシーバそのものなので)次のトリガーへの予約が行われなく、終了となる)。
また、この実装から分かることがもう一つある。スレッドが呼び出されて、何か処理をした後に指定時間を待つという処理だと処理時間分タイミングがどんどんずれていってしまう。SuperColliderのサンプルなんかで次の待ち時間までx.waitなんかの表記をよく見かけて、素直にその時間だけ待つということならテンポは正確じゃないのではないかと思ったのだけど、実際にはその時点から指定時間待つということではなく、Routineを呼び出したトリガー時間から計算して指定時間後に再度トリガーがかかるという実装になってるわけだ。