Exploring Into SuperCollider 6

前回までにClock(TempoClock)によってトリガーを発生させ、そのタイミングでコマンドをシンセサイザノードに送信することによって演奏を行うことが可能だと言うことを見てきた。

ただ、その操作はローレベル過ぎて、確かに可能なのだけど、毎回このような基礎の部分から行わないといけないとなると面倒だ。おそらく、プログラムをかける人なら必ずなんらかの手間の簡略化(クラス化なり)を行うだろう。そしてSuperColliderにもそういった上位のラッパークラスが用意されていて、演奏をさせたいのに、それまでのことに煩わせらるといったことがないようになっている。

今回見ていくのはPatternクラスだ。Patternクラスは豊富な派生クラスが存在していて、名前が示すとおりパターンとして登録しておけばそれを演奏してくれるというものだ。必ずしも最初に決められたものだけではなく、ジェネレーティブなものもある、そしてそれらを複雑に組み合わせることも出来る。

パターンクラスを使うと、例えばこんなコードで演奏をさせることが出来る。

Pbind(\degree, Pseq(#[0,1,2],2)).play

さて、これを見ていると、ふと疑問がわいてくる。魔法というものが存在しないように、前々回見たような処理を誰かが行ってくれているわけだ。それは誰がどのようにして行っているのだろうか。どうやってコマンドは手元からシンセサイザノードに届くのだろうか。

その点に注目してPatternクラスを見ていこう。

Play

入り口はplayメソッドだ。PatternクラスのPlayメソッドはこうなっている。

play { arg clock, protoEvent, quant;
^this.asEventStreamPlayer(protoEvent).play(clock, false, quant)
}

まずasEventStreamPlayerを呼んでいる。asEventStreamPlayerの実装はというと、こんな風になっている。

asEventStreamPlayer { arg protoEvent;
^EventStreamPlayer(this.asStream, protoEvent);
}

EventStreamPlayerクラスのコンストラクタを呼び、そのインスタンスを作成して返すメソッドだ。EventStreamPlayerは名前からもわかるかもしれないけどStreamクラスの派生クラスとなっている。

EventStreamPlayerのコンストラクタの引数、asStreamに注目する。

asStream { ^Routine({ arg inval; this.embedInStream(inval) }) }

こちらもRoutineクラスのコンストラクタを呼んで、そのインスタンスを返している。
このあたりの流れは複雑なのだけど、前回、前々回見たとおり、Clockクラスはトリガー時に登録されているRoutineクラスのインスタンスに対してトリガー処理を行うのだ。

引数にはブロック(関数)を渡している。この流れもRoutineでの演奏を見たときに見ているのだけど、最終的にRoutineのメソッドprStartで実行される。そしてその戻り値となるのだ。戻り値は再びClockに登録されるオブジェクトになるという流れを思い出して欲しい。

asEventStreamPlayerにもどって、EventStreamPlayerクラスはStream.scで実装されている。コンストラクタはこんな感じだ。

*new { arg stream, event;
^super.new(stream).event_(event ? Event.default).init;
}

引数としてstreamとeventをとる。eventはPatternのplayメソッドで引数として与えるprotoEventということになるのだけど、ここは通常何も指定しないのでnilと考えて良いだろう。最後がアンダースコアで終わるメソッドはプロパティへのセッターメソッドだ。そeventがnilの場合はその値はEvent.defaultに設定されることになる。

そしてPlay

さて、Patternクラスのメソッドplayの最後の処理は、asEventStreamPlayerで取得したEventStreamPlayerのインスタンスへのplayメソッド呼び出しだ。このplayメソッドは少々長い。

play { arg argClock, doReset = (false), quant;
if (stream.notNil, { "already playing".postln; ^this });
if (doReset, { this.reset });
clock = argClock ? clock ? TempoClock.default;  // 1
streamHasEnded = false;
stream = originalStream;
isWaiting = true;	// make sure that accidental play/stop/play sequences
// don't cause memory leaks
era = CmdPeriod.era;
quant = quant.asQuant;                         // 2
event = event.synchWithQuant(quant);           // 3
clock.play({                                   // 4
if(isWaiting and: { nextBeat.isNil }) {
clock.sched(0, this );
isWaiting = false;
this.changed(\playing)
};
nil
}, quant);
this.changed(\userPlayed);
^this
}

ポイントを順に見ていこう。(1)はもうおなじみだと思うのだけど、clockの指定がない場合はTempoClock.defaultが使われるという処理だ。

(2)は引数のquantのasQuantというメソッドを呼び出している。なぜそういう処理を行っているかというと、この引数も通常nilになっていて、NilクラスのasQuantを呼び出しているのだ。NilクラスのasQuantはこんな実装になっている。

asQuant { ^Quant.default }

Quantクラスのdefaultを返している。defaultはクラス変数だ。ではQuantクラスのdefaultはというと、

*default { ^default ?? { Quant.new } }

と、なっていて、defaultにセットされていない場合は新たにインスタンスを作成して返している。ところで、quantにちゃんとQuantクラスのインスタンスをセットした場合はどうなるかというと、

asQuant { ^this.copy }

と、自分自身のコピーを返すので問題ないのだ。

ところで、このquantという引数、そして、Quantクラスとは何者なのだろうか。それは実はQuantクラスが実装されているソース、Quant.scの先頭にそのものずばり書かれている。

This class is used to encapsulate quantization issues associated with EventStreamPlayer and TempoClock.
quant and phase determine the starting time of something scheduled by a TempoClock.
timingOffset is an additional timing factor that allows an EventStream to compute “ahead of time” enough to allow
negative lags for strumming a chord, etc.

このクラスはEventStreamPlayerやTempoClockで用いられる量子化(quantization)関連の事柄をカプセル化している。
quantphaseのプロパティはTempoClockによってスケジュールされる開始時間を決定する。
timingOffsetのプロパティはEventStreamPlayerに前方向の時間計算も可能にする追加の時間的要素だ。
マイナス方向のラグ(前のめり)でコードをストラミングするとかね。

そのEventStreamPlayerに対するQuantの処理が、まさしく(3)となる。EventクラスのsynchWithQuantの実装はこうなっている。

synchWithQuant { | quant |
if(quant.timingOffset.notNil) {
^this.copy.put(\timingOffset, quant.timingOffset)
} {
quant.timingOffset = this[\timingOffset];
^this
};
}

ぱっと見ても分かるとおり、中で使用されているのはtimingOffsetだけだ。実は、この実装にもEventクラスの重要な部分が垣間見えているのだけど、とりあえず今はさらっと流しておこう。

そして(4)でついにclock.playとなる。clockはほとんどの場合TempoClockで、その実装は、

play { arg task, quant = 1;
this.schedAbs(quant.nextTimeOnGrid(this), task)
}

となっていて、quant.nextTimeOnGridで指定される時間後にtaskのトリガーを予約している。さて、quantはいまQuantクラスのインスタンスなので、QuantクラスのnextTimeOnGridの実装を見てみると、

nextTimeOnGrid { | clock |
^clock.nextTimeOnGrid(quant, (phase ? 0) - (timingOffset ? 0));
}

となっていて、結局は元のTempoClockのnextTimeOnGridが呼ばれることになる。TempoClockのnextTimeOnGridの実装。

nextTimeOnGrid { arg quant = 1, phase = 0;
if (quant == 0) { ^this.beats + phase };             // 1
if (quant < 0) { quant = beatsPerBar * quant.neg };  // 2
if (phase < 0) { phase = phase % quant };            // 3
^roundUp(this.beats - baseBarBeat - (phase % quant), quant) + baseBarBeat + phase // 4
}

引数が異なっていることに注意だ。またquantもQuantクラスのインスタンスではなく、呼び出し時にQuantクラスのプロパティquantの値になっている。

いまquantは0でphaseはnilなので、(1)によりTempoClockクラスのプロパティ値、Beatsを返して終了してしまう。それでも分かることが一つある。beatsにphaseを足していると言うことはこの二つの単位は同じであると言うことだ。つまり、phaseの単位は(TempoClockの場合)拍ということになる。

面白いのは(2)の処理かもしれない。quantが負の場合、その値を正にしたものにbeatsPerBarをかけている。これはつまり、quantが負の場合その単位系は小節であるというルールだと言うことだ。正の場合はその単位は拍(beat)ということになる。

(3)はphaseが負の場合にその値をquantで丸めている。quantの値の範囲内以上の過去は指定できないと言うことだろうか(Δtがquantだと、それ以上の過去はもう既に過ぎているので指定してもトリガーがかかることがない)。

(4)はroundUp()で前回から一番近い単位時間(quant)単位の時間(単位時間で割りきれる時間)を計算して、そこに基準位置とphaseを足して最終的な時間を算出している。

さて以上でTempoClockにトリガーをかけて演奏のタイミングを待つところまで来た。次回は、後半のトリガーがかかって以降の処理を見ていこうと思う。