らくとあいすの備忘録

twitter : lactoice251

SonicPiをMIDIコントローラで制御する話

こんにちは。

久し振りにSonicPiでライブをしたのですが、今回はMIDIコントローラを初導入しました。

SonicPiでのMIDIコンからの入力の受け方について少しまとめておきたいと思います。 本記事は、程度SonicPiに慣れ親しんでいる方を想定しています。

環境

  • SonicPi for Win x64 v3.3.1
  • DJ TechTools MIDI FIGHTER TWISTER
    • 16個のロータリーエンコーダのついたMIDIバイス。ノブを押し込むことでもMIDI信号を送信出来る。

SonicPiでのMIDIの受け方

Sonic Piでは接続されているMIDIバイスからの入力を、Time Stateと呼ばれる共有メモリに保持しています。

ここは、良くわかってないで書きますが、(SonicPiのTutorialによれば) Time Stateはグローバル変数と似ているがスレッドセーフであるとのことです。 例えば、複数スレッド (ループ) 間で共通の値 (例えばBPM) にアクセスしたいとき、グローバル変数ではそれがあるスレッドで書き換えられると他のスレッドに影響を及ぼしますが、Time Stateの変更はスレッド間で独立に行われるため、determinism guaranteesであるということみたいです。

Time Stateは、シンボル (コロン+文字列) または文字列によって名前付けられる。MIDI入力は次のような文字列でTime Stateに格納されます:

"/midi:[MIDIデバイス名]:[MIDIチャネル]/[MIDIイベント]"

MIDIバイスの名称は、環境設定>入出力>MIDIポートから確認出来ます*1。 格納されたMIDI Time Stateは、get または syncによって取得することが出来ます:

note, velocity = get (or sync) "/midi:[MIDIデバイス名]:[MIDIチャネル]/[MIDIイベント]"

getによる取得は、MIDI Time Stateにその時保持されている値を取り出すもので、syncによる取得はMIDI Time Stateが新しい入力によって更新されたときにその値を取得するものです。 また、MIDI Time Stateの名前はアスタリスク ( * ) によるワイルドカードを用いて指定することが出来ます。 これは例えば、MIDIイベントとMIDIチャネルを "*"に置き換えることによってあるMIDIバイスから入力される信号を全て受け取るといったように利用することが出来ます。

受け取ったMIDI入力を使用出来る形にする

基本方針は次の通りです:

Note On/Off

Note On/Offは今回スイッチ/トグル的な情報の取得 (例えば全ミュートや、乱数の更新) と、手でSFXを鳴らすために使いました。 下記のコードのように、Note On/Offでそれぞれスレッドを用意してそれぞれはON/OFFの入力をsyncで待ちます。そして、やってきたMIDI信号のNoteに応じて処理の振り分けを行います。n==1の場合の処理は、ON/OFFで連動していてONが押されてからOFFまでの間mute=trueとする処理。また、n==11の時はONが押されたら即座にサンプルを再生します。

live_loop:midion do
  use_real_time
  n,v = sync "/midi:midi_fighter_twister_2:1/note_on"
  if n==1
    mute = true
  end
[中略]
  if n==11
    sample fx2s,rand_i(fx2N)
  end
end

live_loop:midioff do
  use_real_time
  n,v = sync "/midi:midi_fighter_twister_2:1/note_off"
  if n==1
    mute = false
  end
end

Control Change

Control changeは、連続的な値の変化 (サンプルを鳴らす頻度、乱雑さ、リバーブの深さ、LPFなど) のために使いました。 ただし、SonicPiでは一度鳴った後の音に対して音を連続的に変化させることが出来ないことには注意が必要で、あくまでも各音を鳴らす時の設定を変化させるというところにとどまります。 Control changeも、MIDI ON/OFFと同じように受け取れますが、複数のノブを同時に回して同一チャネルにCC信号を送ったときMIDI ON/OFFと同じような書き方だと片方が取れなくなるのではないかと思うので [未検証]、今回は各16個のノブを回したときに別のチャネルにControl change信号が送られるようにし、それぞれを独立に受信します。 ただし、この場合に下記のようにsyncを使ってしまうと、ch1のControl change信号が来ないとそれ以降の信号が受け取れないようになってしまう。

live_loop:midicc do
  use_real_time
  _,v1 = sync "/midi:midi_fighter_twister_2:1/control_change"
  _,v2 = sync "/midi:midi_fighter_twister_2:2/control_change"
  _,v3 = sync "/midi:midi_fighter_twister_2:3/control_change"
end

そこで今回はやや無駄があるようにも思われるがgetによって信号を受信することにしました。ただし、getの場合は受信を待ってはくれないので別途sleepが必要となる:

live_loop:midicc do
  use_real_time
  _,v1 = get "/midi:midi_fighter_twister_2:1/control_change"
  _,v2 = get "/midi:midi_fighter_twister_2:2/control_change"
  [中略]
 if v1 != nil
    rbd = v1/127.0
  end  
  if v2 != nil
    rhh = v2/127.0
  end
  [中略]
  sleep 0.3
end

受け取ったMIDI入力を元にリズムや音を作る

今回はリズムの生成アルゴリズムの変数とエフェクトのパラメータの部分に主にMIDI信号を利用した。 明確な機能というよりは、お気持ち的に割り当てている変数が多いですが...下記が実際に行った機能割り当てです。

f:id:Raku_Phys:20211128174420p:plain
MIDIコンへの機能割り当て

ざっくりと各項目について書いていきます。

Control changeのパラメータ

各サンプルの頻度

今回のリズム生成の基本的な方針は、6つのサンプルグループと2つのシンセのいずれかを時間単位あたりひとつだけ鳴らすというもので、この8つのグループから各拍にどれを選択するかという頻度を8つのノブでコントロールします。例えば、1,2番目のノブだけを振り切るとバスドラとハイハットだけが鳴るといったような感じです。

1ループの音数

時間単位あたりひとつだけ音を鳴らすという方針の上では、各拍の音は独立、つまりループという概念は生まれませんが音楽的まとまりの導入(とエフェクトの負荷軽減)のため、いくつかの拍を1まとまりとして「1ループ」という概念を導入しています。ここでは、1ループに含まれる音の数を変更します。

ループのまとまり感

前項のようにループを導入してもそのままでは、各音は完全に独立です。 今回は音楽的なまとまりをかなりヒューリスティックに導入していて、この「まとまり感」変数をあげていくと、ループ先頭でバスドラが鳴りやすく、ループ中間でハットが鳴りやすくなり、逆にそれ以外の拍では鳴りづらくなっていきます。ループの音数が4のときは普通の四つ打ちに近づいていく感じですね。ループの音数を5や7にして変拍子を作り出すことも出来ます。

音程の広がり

今回音程のある楽器として、ベース、単音のシンセ、コードの三つが導入されていますがこれらのうちシンセとコードについて、ある割合でオクターブジャンプするようになっています。その割合をここでコントロールします。

サンプルの種類幅

各リズムサンプルグループ (バスドラ、ハイハット、クラップ、タム、パーカッション、グリッチ系) は、それぞれ複数のサンプルを持っています。この中からどの程度のバリエーションを取り出すかという部分がこの変数です。サンプルの順番として、おとなしめのものが前半に、激しめのものが後半にくるようにしておきます。

HPF/LPF cutoff

サンプルに対するHigh pass, Low pass filterのカットオフです。 ここで、他のエフェクトはループ単位でかけていますが、このエフェクトだけは各拍毎に更新しています。

バーブMIX

バーブのdry/wet割合です。今回はもともと深めのリバーブをかけておいて、dry/wetのmixだけでリバーブをコントロールすることにしています。

PAN

グローバルなPAN振りです。もともと各サンプルはある程度の乱数幅を持ってそれぞれPANが振られていますが、このノブを回すとその乱数幅が収束していって、振り切った時にはL/Rに偏るようになっています。

Note On/Offのパラメータ

MUTE

全ミュートです。ただし、SonicPiは既に鳴らした音をミュート出来ないので、実際には次の拍以降に鳴るはずの音を鳴らさないということをしています。このスイッチは押している間ずっとミュートで、NoteOffを受信したときにミュートを解除するという動作にしています。

ループ内seed固定

1ループごとに固定のseedを設定しなおすかどうかのスイッチです。1ループごとにset_random_seed [seed値]でseedを設定しなおすと、各ループではまったく同じ音が繰り返されることになります。これによって、例えば、1ループの音数が16の時は1小節のリズムパターンのように振舞います。逆にseedを設定しないときは、ループ間ではまったく別の音が鳴ることになります。

seed変更

これは、前項のループ内seed固定が有効な時に効果があります。ループ内seed固定をしているそのseedをこのボタンを押すたびに変更します。これによって、ループパターンを変更して好みのものを「ガチャ」出来るようになります。

Bass ON

これは主にエンコーダが足りないためにBassだけ独立させているだけですが、Bassはバスドラと連動させた動きが多いので、鳴らし方・頻度はバスドラの頻度パラメータに紐づけて、鳴らすかどうかをこのスイッチで決めています。

エフェクトON/OFF

今回は、bitcrusherと、ixi_technoの二つのエフェクトを用意しています。エフェクトのパラメータをいじるのではなく、単にON/OFFをするスイッチです。

SFX

その他のNote ON/OFFと違って楽器的な振舞いをします。ボタンを押したタイミングで、性質の違う3つのSFXグループからランダムなサンプルを選んでそれぞれ再生されます。

時間単位

最小時間単位の長さを変更します。例えば、最小時間単位が1のときに比べて1/4の時は4倍のテンポになるといった感じです。

まとめ

Sonic PiをMIDIコンで制御するために、MIDI入力を受け取る方法とそれを使った実際の制御方法例を示しました。 別の使い方としては、シンセサイザーのように各シンセのパラメータを変更したりするようにも使っている人が多いようですね *2。 最後に、参考までに全体コードを載せておきます。コメントも何もないのでかなり見づらいかもしれませんが... 改変等はご自由にどうぞ!

use_bpm 138

load_synthdefs "C:/Synth/my-synth"
set_volume! 5
bds = "C:/SPS/gc1127/bd/"
bdN = 2
hhs = "C:/SPS/gc1127/hh/"
hhN = 2
claps = "C:/SPS/gc1127/clap/"
clapN = 3
toms = "C:/SPS/gc1127/tom/"
tomN = 6
percs = "C:/SPS/gc1127/perc/"
percN = 54
glitchs = "C:/SPS/gc1127/glitch/"
glitchN = 30
fx1s = "C:/SPS/gc1127/fx1/"
fx1N = 3
fx2s = "C:/SPS/gc1127/fx2/"
fx2N = 3
revs = "C:/SPS/gc1127/rev/"
revN = 4
Ns = [3,4,5,6,7,11,16]
Ms = [0.25,0.5,3.0/4.0,1.0]
key = -4
bsl = [:C3,:C3,:C2,:C2,:C2,:C2,:C2,:C2,:C2,:C1,:C1,:C1,:C1,:Db1,:Db2,:G1,:G2,:Db3].ring
synl = [:C2,:C3,:C3,:F3,:G3,:Bb3,:C4,:Db4,:Eb4,:F4,:G4,:Bb4,:C5].ring
cdl = [:C4,:C4,:Eb4,:F4,:C5]

mute = false
fixseed = false
withbs = false
seed = 0
rbd = 0
rhh = 0
rclap = 0
rtom = 0
rperc = 0
rglitch = 0
rsyn = 0
rcd = 0
octr = 0.0
width = 0.6
gpan = 0.0
acc = 0
var = 0.0
rev = 0.0
hpfco = 0.0
lpfco = 0.0
N = 4
M = 1
ixi = 0
bit = 0

set_mixer_control! hpf:0,hpf_slide:0.5
set_mixer_control! lpf:127,lpf_slide:0


live_loop:metro do
  sleep 4
end

live_loop:loop1,sync: :metro do
  F = 4.0 - var*4.0
  fbd = 1.0
  fhh = 1.0
  it = 0
  if fixseed
    use_random_seed seed
  end
  with_fx :reverb,room:rev,mix:0.2+rev*0.8 do
    with_fx :ixi_techno,res:0.9,mix:ixi do
      with_fx :bitcrusher,bits:5,mix:bit do
        N.times do
          with_fx :rhpf,cutoff:hpfco,res:0.9 do
            with_fx :rlpf,cutoff:lpfco,res:0.9 do
              if not mute
                oct = 12*(5.0*octr*rand-1.0).to_i
                frbd = rbd
                frhh = rhh
                if it==0
                  frbd = rbd*(1.0 + 50.0*acc)
                else
                  frbd = rbd/(1.0 + acc*20.0)
                end
                if it==(N/2.0).to_i
                  frhh = rhh*(1.0 + 50.0*acc)
                else
                  frhh = rhh/(1.0 + acc*20.0)
                end

                rsum = frbd+frhh+rclap+rtom+rperc+rglitch+rsyn+rcd
                if rand<rsum
                  r = rsum*rand
                  if r<frbd
                    sample bds,(bdN*rand**F).to_i,amp:0.75,pan:gpan
                    with_synth:sine do
                      play 30,amp:0.75,pan:gpan
                    end
                    if withbs
                      with_synth :tb303 do
                        play bsl.choose+0+key,amp:0.4,release:0.3+0.1*rand,cutoff:60+70*rand if rand<0.8*(1.0 - acc)
                      end
                    end
                  elsif r<frbd+frhh
                    sample hhs,(hhN*rand**F).to_i,amp:0.75,pan:(gpan + 0.2 + (rand**0.5 - 0.2)*width).clamp(1)
                  elsif r<frbd+frhh+rclap
                    sample claps,(clapN*rand**F).to_i,amp:0.75,pan:(gpan + -0.2 + (0.2 - rand**0.5)*width).clamp(1),rate:1.0 + 2.0*(rand-0.5)*var*var
                  elsif r<frbd+frhh+rclap+rtom
                    sample toms,(tomN*rand**F).to_i,amp:0.9+0.2*rand,pan:(gpan + -0.4 + (0.4 - rand**0.5)*width).clamp(1)
                  elsif r<frbd+frhh+rclap+rtom+rperc
                    sample percs,(percN*rand**F).to_i,amp:0.3+0.8*rand*rand,pan:(gpan + width*(rand**0.5-0.5)*2.0).clamp(1),rate:1.0 + 3.0*(rand-0.5)*var*var
                  elsif r<frbd+frhh+rclap+rtom+rperc+rglitch
                    sample glitchs,(glitchN*rand).to_i,amp:0.3+0.8*rand*rand,pan:(gpan + width*(rand**0.5-0.5)*1.5).clamp(1),rate:1.0 + 4.0*(rand-0.5)*var*var
                  elsif r<frbd+frhh+rclap+rtom+rperc+rglitch+rsyn
                    with_synth :myssaw do
                      play synl.choose+0+key+oct,release:0.3+0.13*rand,amp:0.15+0.3*rand
                    end
                  elsif r<frbd+frhh+rclap+rtom+rperc+rglitch+rsyn+rcd
                    with_synth :dtri do
                      play chord(cdl.choose+key+oct,'m7'),release:0.2,amp:1.5
                    end
                  end
                end
                if withbs
                  with_synth :tb303 do
                    play bsl.choose+0+key,amp:0.3+rand*0.22,release:0.18+0.1*rand,cutoff:40+70*rand if rand<acc*rbd
                  end
                end
                with_synth :myssaw do
                  play synl.choose+0+key+oct,release:0.2+0.04*rand,amp:0.15+0.15*rand if rand<rsyn
                end

              end
              it += 1
              sleep 0.25/M
            end
          end
        end
      end
    end
  end
end

live_loop:midion do
  use_real_time
  n,v = sync "/midi:midi_fighter_twister_2:1/note_on"
  if n==1
    mute = true
  end
  if n==2
    fixseed = !fixseed
  end
  if n==3
    seed = seed + 7
  end
  if n==4
    withbs = !withbs
  end
  if n==5
    bit = 1.0 - bit
  end
  if n==6
    ixi = 1.0 - ixi
  end
  if n==9
    sample revs,rand_i(revN),amp:1.5
  end
  if n==10
    sample fx1s,rand_i(fx1N)
  end
  if n==11
    sample fx2s,rand_i(fx2N)
  end

  if n>=13
    M = Ms[n-13]
  end
end

live_loop:midioff do
  use_real_time
  n,v = sync "/midi:midi_fighter_twister_2:1/note_off"
  if n==1
    mute = false
  end
end

live_loop:getmidicc do
  use_real_time
  _,v1 = get "/midi:midi_fighter_twister_2:1/control_change"
  _,v2 = get "/midi:midi_fighter_twister_2:2/control_change"
  _,v3 = get "/midi:midi_fighter_twister_2:3/control_change"
  _,v4 = get "/midi:midi_fighter_twister_2:4/control_change"
  _,v5 = get "/midi:midi_fighter_twister_2:5/control_change"
  _,v6 = get "/midi:midi_fighter_twister_2:6/control_change"
  _,v7 = get "/midi:midi_fighter_twister_2:7/control_change"
  _,v8 = get "/midi:midi_fighter_twister_2:8/control_change"
  _,v9 = get "/midi:midi_fighter_twister_2:9/control_change"
  _,v10 = get "/midi:midi_fighter_twister_2:10/control_change"
  _,v11 = get "/midi:midi_fighter_twister_2:11/control_change"
  _,v12 = get "/midi:midi_fighter_twister_2:12/control_change"
  _,v13 = get "/midi:midi_fighter_twister_2:13/control_change"
  _,v14 = get "/midi:midi_fighter_twister_2:14/control_change"
  _,v15 = get "/midi:midi_fighter_twister_2:15/control_change"
  _,v16 = get "/midi:midi_fighter_twister_2:16/control_change"
  if v1 != nil
    rbd = v1/127.0
  end

  if v2 != nil
    rhh = v2/127.0
  end

  if v3 != nil
    rclap = v3/127.0
  end

  if v4 != nil
    rtom = v4/127.0
  end

  if v5 != nil
    rperc = v5/127.0
  end

  if v6 != nil
    rglitch = v6/127.0
  end

  if v7 != nil
    rsyn = v7/127.0
  end

  if v8 != nil
    rcd = v8/127.0
  end

  if v9 != nil
    N = Ns[(7.0*(v9-1.0)/127).to_i]
  end

  if v10 != nil
    acc = v10/127.0
  end

  if v11 != nil
    octr = v11/127.0
  end

  if v12 != nil
    var = v12/127.0
  end  

  if v13 != nil
    hpfco = v13
  end

  if v14 != nil
    rev = v14/127.0
  end

  if v15 != nil
    gpan = 2.0 * (v15/127.0 - 0.5)
  end

  if v16 != nil
    lpfco = v16
  end
  sleep 0.3
end

*1:MIDIバイスを入力した順番によって、名称の末尾の数字が変更されうるので注意が必要です。

*2:Nikoさんとか寝る前さんとか