2022年9月30日

CAL スクリプトを書く 上級編


Cakewalk のプログラミング言語 CAL を使ってスクリプトを自作する方法をメモメモ…



このページの内容の動作確認には Cakewalk by BandLab 2022.06 を使用しています。その後のアップデートで仕様が変更されている場合があります。



Cakewalk Application Language 通称 CAL について解説するシリーズもいよいよ上級編です。正直ここまでくると仕様を把握できていない部分も出てくるのですが、とりあえずメモ代わりに今理解している範囲内で解説します。間違っている部分があれば指摘してもらえると嬉しいです。



while ループ


繰り返しの処理を行いたいときはwhileを使います。プログラミング言語ではお馴染みですね。

(while 条件式 処理)

これで「条件式が真であるかぎり処理を繰り返す」という意味になります。たとえば選択した各ノートをn個に分割するスクリプトは次のように書けます。

; Divider.cal

(do
  (word div 2)
  (word tempDiv 0)
  (word tempDur 0)

  (getWord div "Input Division Number" 2 128)
)

(if (== Event.Kind NOTE)
  (do
    (= tempDiv 0)
    (= tempDur (/ Note.Dur div))
    (while (< tempDiv div)
      (do
        (insert (+ Event.Time (* tempDur tempDiv )) Event.Chan NOTE Note.Key Note.Vel tempDur)
        (++ tempDiv)
      )
    )
    (delete)
  )
)

難しそうに見えますが、基本的には15~20行目で短いノートを div 回挿入し、21行目で元のノートを削除しているだけです。

ただし while を使うときは無限ループにならないよう注意してください。この例の場合、たとえば16行目の(++ tempDiv)を書き忘れると無限ループが発生します。そうなると Cakewalk のアプリが固まってしまい強制終了するしかなくなるので、実行前にファイルの保存を忘れないようにしましょう。動作確認やデバッグを行うときには(pause ...)を利用してループごとに一時停止するのが安全かもしれません。

ちなみに、条件式の結果の真偽値も実際は整数値にすぎません。真なら1で偽なら0が返されます。さらにifwhile0以外をすべて真だと判定するため、たとえば次のように書くと無限ループが発生します。

(while 12345 (pause "Infinite Loop..."))

TRUE と FALSE という定数も定義されているので(それぞれ値は1と0)、フラグ的な変数を利用したい場面で使ってみてください。


forEachEvent ループ


続いて CAL ならではの特殊なループであるforEachEventを紹介します。

(forEachEvent 処理)

これは、現在選択されているすべての MIDI イベントに対して1回ずつ処理を実行するための関数です。条件式などはありません。1つ1つのイベントには Event.Time や Note.Key といった変数を通してアクセスできます。たとえば、選択中のすべてのノートを1オクターブ上げる操作は次のように書けます。

(forEachEvent
  (if (== Event.Kind NOTE)
    (+= Note.Key 12)
  )
)

…って、いやいや!それ CAL ファイルの2つ目の式に書くのとほとんど一緒やん!とツッコんだあなた、実はその通りです。Cakewalk のアプリ上で CAL ファイルを実行したとき、内部的にはこのforEachEventが呼び出されていると思われます。

そしてここからが重要なポイントなのですが、基本的にforEachEvent入れ子にできません。ネストした場合、内側のループが終了しても Note.Key などの変数の参照先が戻らなかったり、変数の値を書き換えても全体のループが終了した段階で変更がリセットされてしまう、といった予期しない挙動になります。

したがって、次のように CAL ファイルの1つ目の式で補助的に使うのが一般的だと思います。

; InversionAbove.cal

(do
  (word maxKey 0)
  (word minKey 127)
  (word tempKey 0)
  
  (forEachEvent
    (if (== Event.Kind NOTE)
      (do
        (if (> Note.Key maxKey)
          (= maxKey Note.Key)
        )
        (if (< Note.Key minKey)
          (= minKey Note.Key)
        )
      )
    )
  )
)

(if (&& (== Event.Kind NOTE) (== Note.Key minKey))
  (do
    (= tempKey Note.Key)
    (while (< tempKey maxKey)
      (+= tempKey 12)
    )
    (if (<= tempKey 127)
      (= Note.Key tempKey)
    )
  )
)

この例では、変数宣言の直後にforEachEventを使って最低音と最高音を取得しています。ちなみに、これは選択した和音の最低音(ベース)を高くして転回させるスクリプトです。逆の(最高音を低くする)バージョンも作ってショートカットに登録しておくとコード進行を入力するときに便利かもしれません。

それから、CAL はforEachEventループの実行ごとに1回の操作として記録されるようなので注意してください。Cakewalk では実行した操作を戻す(編集→「元に戻す」)ことができますが、このスクリプトを実行したあと編集→「履歴」で確認すると、2回分の操作として登録されているはずです。


時間に関する処理


時間絡みの関数や定数が多くあるので、特に使えそうなものをここにまとめておきます。


  • (meas 時間)
    指定した時間が何小節目にあたるかを返す関数。
  • (beat 時間)
    指定した時間が何拍目にあたるかを返す関数。
  • (tick 時間)
    指定した時間が何ティック目にあたるか(拍の頭を0ティックとしてどれだけ後ろにあるか)を返す関数。
  • (makeTime 小節数 拍数 ティック数)
    小節数、拍数、ティック数を時間に変換する関数。
  • (getTime 変数名 表示メッセージ)
    ユーザーの入力を受け取る関数。小節:拍:ティックの形式で入力できる。
  • TIMEBASE
    四分音符あたりのティック数(TPQN)を示す定数。たとえば仮にこの値が 960 だった場合、4/4 拍子の曲であれば 960 * 4 つまり 3840 が1小節分のティック数ということになる(6/8 拍子なら 2880)。CALで値を書き換えることはできないが、メニューの「環境設定」→「プロジェクト」→「クロック」から値を変更することは可能。ただ上記の関数があればこちらを使う機会はほとんどない。

Event.Time は曲の冒頭からのティック(分解能)数を表すものでしたが、上記の関数を使えばこれを簡単に小節数や拍数に変換することができます。

たとえば1拍目と3拍目にあるノートのベロシティを強くするスクリプトは次のように書けます。

; BasicBeat.cal

NIL
(if (== Event.Kind NOTE)
  (do
    (if (== (beat Event.Time) 1) (+= Note.Vel 20))
    (if (== (beat Event.Time) 3) (+= Note.Vel 10))
  )
)

逆に、小節数、拍数、ティック数を指定して時間(曲冒頭からのティック数)に変換するにはmakeTime関数を使います。

; イベントを4小節目の1拍目に移動
(= Event.Time (makeTime 4 1 0))
; 1拍分のティック数を取得
(= ticksPerBeat (makeTime 1 2 0))

getTime関数を使えばユーザーに小節数や拍数を入力してもらうことも可能です。

(dword time 0)
(getTime time "Input Time")

もちろん中級編で紹介したgetWordを使ってティック数を直接入力してもらうこともできますが、こちらを使えば小節数や拍数で指定することができるので便利です。目的に応じて使い分けましょう。

getTimeの入力プロンプト 正直使ったことはない

マーカーに関する処理


これも時間(位置)に関連した機能ですが、実はセレクションマーカーも CAL から操作することが可能です。

「セレクションマーカー」というのはこれ
Now再生開始位置を示す変数。
Fromセレクションマーカーの開始位置を示す変数。
Thruセレクションマーカーの終了位置を示す変数。
End曲全体の最後のイベント位置を示す変数。読み取り専用で、直接書き換えようとするとエラーになる。

NowFromThruの3つはスクリプトから値を書き換えることができ、これらを利用すれば対象となるイベントの選択範囲を動的に変更することも可能になります。

; トラックの最初から現在の再生開始位置までにあるすべてのイベントを選択する
(= From 0)
(= Thru Now)

ただし、そもそもイベントを1つも選択していない状態でスクリプトを実行するとマーカー位置を指定しても選択状態にならなかったり、Thruと同じ位置にあるイベントは選択されなかったりと、若干挙動にクセがあるので、まずはいろいろ試してみるのがいいと思います。


作ってみよう! ~ギターストローク~


さて、毎度ご好評をいただいている作ってみよう!のコーナーです。今回は気合を入れてギターストロークを再現するスクリプトを書いてみました。

Before:
After:

Before は音が揃いすぎてかなり不自然ですが、After は「ポロロン…」というギターっぽい音色に近づいていますよね。

使い方としては、適用させたい範囲にあるノート(トラック全体でもOK)をすべて選択した状態で CAL を実行、音の間隔を入力するだけです。あとは自動的に和音を判別してギターのストローク風にタイミングをずらします。

; GuitarStrum.cal

(do
  (word interval 30)      ; 音と音の間隔
  (word maxKey 0)         ; 選択中のすべてのノートで最も高いキー
  (word minKey 127)       ; 選択中のすべてのノートで最も低いキー
  (word targetKey 0)      ; 現在処理しているキー 高いキーから低いキーへ順にループを回して処理する
  (word hasNotes FALSE)   ; 現在処理しているキーにノートが存在するかどうか
  (dword maxTime 0)       ; 現在処理しているキーで最も後ろにあるノートの位置
  (dword targetTime 0)    ; 現在処理しているノートの位置
  (dword prevTime 0)      ; 前回処理したノートの位置
  (word keysBelow 0)      ; 同時に再生されるノートのうち現在処理しているノートよりも低いノートの数

  (getWord interval "Interval" 1 960)

  ; 選択中のノートのキーの範囲を取得
  (forEachEvent
    (if (== Event.Kind NOTE)
      (do
        (if (> Note.Key maxKey)
          (= maxKey Note.Key)
        )
        (if (< Note.Key minKey)
          (= minKey Note.Key)
        )
      )
    )
  )

  (= targetKey maxKey)
  (while (>= targetKey minKey)
    (do
      ; 現在のキーにノートが存在するか確認 ついでに最後のノートの位置も取得しておく
      (= hasNotes FALSE)
      (= maxTime 0)
      (forEachEvent
        (if (&& (&& (== Event.Kind NOTE) (== Note.Key targetKey)) (>= Event.Time maxTime))
          (do
            (= hasNotes TRUE)
            (= maxTime Event.Time)
          )
        )
      )
      (if hasNotes
        (do
          (while (< prevTime maxTime)
            (do
              (= targetTime maxTime)
              ; ノートの位置を取得
              (forEachEvent
                (if (&& (&& (&& (== Event.Kind NOTE) (== Note.Key targetKey)) (> Event.Time prevTime)) (<= Event.Time targetTime))
                  (= targetTime Event.Time)
                )
              )
              ; 同じ位置にあるノートかつ現在のキーより低いものの数をカウント
              (= keysBelow 0)
              (forEachEvent
                (if (&& (&& (== Event.Kind NOTE) (== Event.Time targetTime)) (< Note.Key targetKey))
                  (++ keysBelow)
                )
              )
              ; カウントしたノートの数に応じて開始位置を後ろにずらす
              (forEachEvent
                (if (&& (&& (== Event.Kind NOTE) (== Event.Time targetTime)) (== Note.Key targetKey))
                  (do
                    (-= Note.Dur (* interval keysBelow))
                    (+= Event.Time (* interval keysBelow))
                  )
                )
              )
              (= prevTime targetTime)
            )
          )
        )
      )
      ; キーをひとつ低くして次のループへ
      (-- targetKey)
      (= prevTime 0)
    )
  )
)

長くてスミマセン…。上で紹介したFromThruを使えばもう少しわかりやすくて効率的なコードが書けるような気もしますが、とりあえずこれでも意図したとおりに動くのでよしとします。forEachEventが連発されているため、実行前の状態に戻したい場合は「元に戻す」を連打するか「履歴」から過去の操作をまとめてリセットする必要があります。

ちなみに、同じような機能を VST3 プラグインとして実装してみたら驚くほど簡単でした…。C++ が使えるのは楽ですね…。


おわりに


とりあえず、これで「CAL スクリプトを書く」シリーズは終了です。他にも CAL ではファイル保存や選択範囲のコピー/貼り付けといったメニュー関連の操作が可能だったり、(include ファイル名)で他の .cal ファイルを読み込んだりもできるらしいのですが、ひとまず今回は割愛します。暇があれば書くかも…。

CAL の学習にあたっては D. Glen Gardenas 氏の The Cakewalk Application Language Programming Guide for SONAR という PDF ファイルが非常に大きな助けになりました。ただ現状では正式な公開ページが見当たらず、第三者がアップロードしたファイルを拾ってくるしかないため、ここでこれ以上紹介するのは控えます。

もっと扱いやすい言語にアップデートを…なんていう贅沢は言わないので、BandLab にはせめて CAL サポートを継続してもらいたいですね。


2 件のコメント:

  1. 初めまして。
    既存のCALソースを少し改造して使いたいと思い、CALについて調べものをしていたのですが、公式ドキュメントは見当たらず、日本語の(特に近年の)解説はほとんど無い中で、貴重な解説をありがとうございます。

    返信削除
    返信
    1. お役に立てたようで何よりです!

      削除