らくとあいすの備忘録

twitter : lactoice251

GLSLで音を作る

こんにちは、らくとあいすです。

つぶやきGLSLというというTwitterハッシュタグをご存じでしょうか?詳しくはハッシュタグを実際に見てもらうか、ブタジエンさんの記事*1をみると雰囲気をつかめると思いますが、簡単に言えば1ツイートの中に収まるGLSLシェーダーを書き、生成されたGIF画像と共にツイートする遊びです。 

さて、普通シェーダーは絵作りのために用いられるものですが実は音を作ることも出来ます(?) 正確には、音の波形を表す配列(=テクスチャ)を生成するのにシェーダーを用い、それを読み取って音を出す仕組みが提供されています*2。ここではこのようにGLSLシェーダーを用いて作られた音楽のことをGLSLサウンドと呼びます。

最近このGLSLサウンドをつぶやきGLSLでやってみたところ、ありがたいことに多くの反応を頂けたのでその解説やGLSLサウンドの初歩的なところを書いていこうと思います。

GLSLサウンドを作る環境

GLSLサウンドを作れる環境として私の知っている範囲では、ShaderToy*3、Veda*4、twigl*5などがあります。ここでは#つぶやきGLSLに特化して作られた環境であるtwiglを標準環境として進めます。こちらtwigl.appからtwiglを開き、触りつつ読み進めてもらえればと思います。

GLSLサウンドチュートリアル

ここではチュートリアル的にGLSLサウンドでの音の鳴らし方や作り方について初歩的な事項を解説します。

Sin波を鳴らす

まずは、GLSLサウンドHello World とも言えるSin波の出力をしてみましょう。twiglのエディタ右側のSound ShaderスイッチをオンにするとSound Shader用のエディタが展開されます。そして再生ボタンもしくはalt+Enterを押すことで音が再生されると思います。この初期コードに入力されている音が(指数減衰する)Sin波です。このコードについて少し詳しく解説します。

f:id:Raku_Phys:20200418132521p:plain
指数減衰するSin波を出すコード
f:id:Raku_Phys:20200418141922p:plain
指数減衰するsin波の出力波形
まず、mainSoundの入力はfloatの実時間で、出力はvec2です。このvec2(2次元ベクトル)は、ステレオ出力におけるLとRの2チャンネルを表します。今回はvec2の中に一つの値しか入っていないので、LとRがまったく同じ出力=モノラルの音が得られます。

続いてsin関数の中身ですが、周波数を f, 時間を tとして \sin(2\pi f t) の形をしています。つまり1秒間に f回振動する(= f{\rm Hz}の) Sin波です。ここでは f=440{\rm Hz}となっていますので音叉などで基準音として用いられるAの音が鳴ります。試しに fの値を色々と変えて音が変わることを確認してみましょう。

最後にexp関数についてです。これはsin関数に掛かる形をしているのでsinの振幅に影響を与えることがわかります。中身は \exp(-at)の形をしていて、指数減衰することと、 aがその速さを決めることがわかります。試しに aの値を色々と変えて減衰速度が変わることを確認してみましょう。また、expの項を取り除き音が減衰しないことを確認しましょう。

Sin波を混ぜる

では次にSin波を混ぜて和音を作ってみましょう。

vec2(sin(6.2831*440.*time)+sin(6.2831*440.*1.5*time))

一つ目のSin波は周波数440Hz、二つ目のSin波は周波数440*1.5=660Hzで、完全5度*6の響きとなる...はずですが鳴らしてみるとどうもSin波では無さそうな音が聴こえると思います。この時の出力波形は次のようになっています:

f:id:Raku_Phys:20200418145015p:plain
クリッピング波形
波形を見ると波形の上端と下端が平坦になっていることがわかります。これは、音声は-1から1の間のfloatで扱われるため、それを超える部分についてはクリップされてしまうことに起因します。これはこれでこういう音として利用しても良いですが、ここでは全体に係数をかけて綺麗なSin波を再生してみましょう。

vec2(.4*(sin(6.2831*440.*time)+sin(6.2831*440.*1.5*time)))

このように、音を混ぜる際には出力が意図しないclippingを起こしていないかを常に注意しておく必要があります。

周期的なエンベロープを作る

今までは一度減衰して消えてしまう音や、常になり続けるような音だけを作ってきました。次は周期的に鳴るような音を作ってみましょう。 ある波形に対してその振幅の変化 (包絡線) をエンベロープと言います*7。例えば"Sin波を鳴らす"で触れた例では、エンベロープとして指数減衰が使われています。 指数減衰は一度下がると上がることはないため、音が一度しか立ち上がりません。逆に言えば何度も立ち上がる=周期的な関数を使えば周期的に鳴るような音を作ることができます。 ここではまずfract関数、すなわち入力の少数部分を取る関数をエンベロープに使ってみましょう。

f:id:Raku_Phys:20200418150858p:plain
fract関数を使った周期的エンベロープ
グラフを見るとわかるように {\rm fract}(at)は0から1までを 1/a秒で上昇し、そして再び0に戻る関数です。したがってこれは周期 1/aエンベロープと言えます。これを使ってSin波を周期的に鳴らすコードは次のようになります:

vec2(sin(6.2831*440.*time)*fract(2.*time))

これでSin波が"ふわっと"持ち上がることを繰り返すようになりました。次にfractの中身を時間反転させてエンベロープを反転させてみましょう:

vec2(sin(6.2831*440.*time)*fract(-2.*time))

これで鋭い立ち上がりのSin波が繰り返されるようになりました。 この他にもゆっくりと振動するsin波やpowerで形を変形したfract、三項演算子で条件分岐したものなど様々なエンベロープを考えることが出来ます。ぜひ色々と試してみて下さい。

ノイズを使う

ここまでに揃ったSin波・加算・エンベロープでかなり多様な表現が出来るようになりました。最後にもう一つ重要な要素としてノイズを紹介します。空洞を持たない金属(ハイハットやシンバルなど)を叩く音は、通常整数倍音等決まった周波数が強調されず幅広い周波数の音が一斉に放出されます。Sin波をベースにした音の構成ではこのような、幅広い周波数を連続に持つ音を表現することが難しいため、ノイズ成分が重要となってきます。

ビジュアルを作るGLSLで良く使われるランダムノイズとしては、次のようなものがあります:

fract(sin(dot(co.xy,vec2(12.9898,78.233))) * 43758.5453);

ここではノイズの生成について (筆者は詳しくないので) 深く立ち入ることはしませんが、このランダムノイズの関数をベースにそれらしく聴こえる音のノイズ関数として次のようなものを作ってみました:

return vec2(fract(sin(time*1e3)*1e6)-.5);

高速に振動するsinに大きい値をかけてfractしたらrandomらしい挙動になるでしょうという適当なものですが、スペクトルを見るとおおよそホワイトなノイズになっているようです。

f:id:Raku_Phys:20200418162536p:plain
生成したノイズの周波数スペクトル
さてこのノイズに”周期的なエンベロープを作る”で作った周期的なエンベロープを付与し、ハイハットを作ってみましょう。例えば時間反転したfractをエンベロープとして用いると次のようになります:

return vec2((fract(sin(time*1e3)*1e6)-.5)*fract(-time*8.));

機械の動作音のようなものが出来ましたね。ここからエンベロープを工夫するだけでも色々な表現を作っていくことができます:

return vec2((fract(sin(time*1e3)*1e6)-.5)*pow(fract(-time*4.),mod(time*4.,2.)*8.));

色々と試して好みの音を作ってみて下さい。

つぶやきGLSL作品の解説

ここではGLSLサウンドの基本的な知識を前提として、冒頭に載せた作品についての解説をしていきます。

全体像

実際につぶやいたコードは次のようなものです

#define f fract 
#define s(a,b) sin(1e3*a+sin(3e2*a))*pow(f(mod(-a*8.,8.)/3.),6.-3.*b)
#define d(a)+exp(-3.*a)*vec2(s(8.*t+a*.3,a),s(8.*t+a*.5,a))
vec2 mainSound(float t){return .3*vec2(3.*sin(3e2*t)*pow(f(-t*2.),4.)+.5*sin(4e5*t)*f(-t*2.+.5)d(0.)d(.5)d(1.)d(2.));}

圧縮がかかっていて読みづらいため、字数制限を考えず同じ音がなるように展開したものが次のコードです:

float fm(float time){
  return sin(1000.*time+sin(300.*time));
}
float rhy(float time,float f){
  return pow(fract(mod(-time*8.,8.)/3.),6.-3.*f);
}
vec2 dfm(float time,float dt){
    return exp(-3.0*dt)*
        fm(8.*time)*
        vec2(rhy(time-.3*dt,dt),rhy(time-.5*dt,dt));
}
vec2 mainSound(float time){
  vec2 s;
  s += vec2(3.0*sin(3e2*time)*pow(fract(-time*2.),4.));
  s += vec2(0.5*sin(4e5*time)*fract(-time*2.+.5));
  s += dfm(time,0.0);
  s += dfm(time,0.5);
  s += dfm(time,1.0);
  s += dfm(time,2.0);
  return 0.3*s;
}

展開後のコードをベースに要点となる要素を見ていきます。全体はバスドラムハイハット・パーカッションの3つのパートから出来ています。

バスドラム

バスドラム4つ打ちは文字数もあまり使えないのでsin波のみで作っています。アタック感を出すためにエンベロープに用いているfractを4乗してカーブを急にしています。

return vec2(3.0*sin(3e2*time)*pow(fract(-time*2.),4.))

ハイハット

裏打ちのハイハットは上で述べたような乱数にエンベロープをかけた形で作ろうと考えましたが、乱数を生成する文字数が足りませんでした。そこでsin波にものすごく大きい値(サンプリング周波数よりも十分大きい値)を入れれば取り出される値が乱数的になるのではないかというゴリ押しで作っています。結果的には不安定な仕組みで作っているがゆえに、timeが大きくなるにつれ音が変わっていきなかなか面白い効果が得られました。

return vec2(0.5*sin(4e5*time)*fract(-time*2.+.5));

ちなみにツイートした動画の終盤ではこのハイハット部分を次のように改造して効果音的に用いています。なんでこんな音がなるのかは正確には良く分かりません

return vec2(0.5*sin(2e6*time));

パーカッション

パーカッションパートは、FM音源、不均等なリズム、クロスディレイという三つの要素からなっています。

FM音源

関数fmでは、周波数変調(Frequency Modulation) を用いて音を作っています。FM音源の波形は次の式で与えられます:

 \sin(2\pi ft+A\sin(2\pi f_M t))

この式の形から音を想像することは容易ではないと思いますが、明らかなこととしては A=0においてこの式は周波数 fのsin波と一致します。そしてAが大きくなるにつれて位相が”かく乱”されて音が変化しそうという見通しを立てることはできます。FM音源の音の性質についてここで詳しくは書きませんが*8、いくつか知られている性質を紹介します。FM音源の音色はもとのSin波の周波数 fと変調周波数f_Mの比r = f_M/fによって大きく性質を変えます。良く知られている比率は1,2,3,3.5等ですが、色々といじって好きな音にたどり着くまで探索してみると良いと思います。

vec2 mainSound(float time){
  float r = 3.0;// FM/F
  float freq = 440.0;//F
  return vec2(sin(6.4831*freq*time+sin(6.4831*freq*r*time))*fract(-time*1.));
}

実際のコードでは、パーカッション的なあまり音程の定まらない音を作る目的があったので、綺麗な比率ではなく3/10という半端な比率を用いることにしました。(あくまでも探索的に見つけた値です。)

不均等なリズム

fract等の周期関数を使って一定のリズムを刻む音を作ることは出来ますが、ユークリッドリズム*9等の不均等なリズムを作ることはなかなか難しいです。 今回のコード上の関数rhyでは次のリズムを作っています:

[10100100]

ここで1は音の鳴る拍、0は休符を表します。言葉で書けば「カッカッッカッッ」というリズムです。このリズムの肝は休符が一か所だけ詰まっている点で、今回はこれを作るためにmodとfractの組み合わせを用いました。つまり周期3のfractエンベロープfract(-t/3.)の中でtを周期8で折りかえすfract(mod(-t*8.,8.)/3.)ことでこのリズムを作っています。

f:id:Raku_Phys:20200418192339p:plain
fractとmodの組み合わせによる不均等なリズム
sin波に対してこのエンベロープを設定したサンプルコードを載せておきます。音を聴いたり値を色々と変えてみて下さい。

return vec2(sin(6.4831*440.*time)*fract(mod(-time*8.,8.)/3.));

クロスディレイ

FM音源と不均等なリズムの掛け合わせで作ったパーカッションに立体的な”響き”を持たせるために、クロスディレイというエフェクト処理を行いました。クロスディレイとは原音がLRにふれながら減衰しつつ繰り返されるエフェクトのことです。つまり、ある時刻の波形を f(t)として、

 \sum_{i=0}^{N}\exp(-a_i t){\rm vec2}( f(t-dt_{L_i}),f(t-dt_{R_i}))

などと書き表すことが出来ます。これをSin波に対して適用したミニマムなコードは次のようになります。

float rhy(float time,float fade){
  return pow(fract(-time),6.0-fade*3.0);
}
vec2 delay(float time,float dt){
    return exp(-2.0*dt)*sin(6.4831*440.0*time)*vec2(rhy(time-dt*.3,dt),rhy(time-dt*.5,dt));
}
vec2 mainSound(float time){
  vec2 s;
  s += delay(time,0.0);
  s += delay(time,0.5);
  s += delay(time,1.0);
  s += delay(time,1.9);
  return 0.5*s;
}

delayの関数の中身を見るとdtと共に減衰する指数関数と、sin波の掛け合わせの後に、vec2のrhy関数(ここではfract)が掛け合わされています。さらにrhy関数の中身は左右で異なるdt幅(L:0.3dt, R:0.5dt) をずらした時刻が入力されています。 rhy関数は時刻と"fade"の二つの入力を持ち、fade=dtとして遅い残響ほど音の立ち下りをゆるやかにする効果を作っています。

f:id:Raku_Phys:20200419001033p:plain
クロスディレイを適用したsin波の波形

まとめ

今回はGLSLサウンドの初歩的な取り扱い及び実際の作品の解説を行いました。GLSLサウンドは基本的に波形を数式に落とし込む作業なので、少しはじめのハードルを高く感じやすいように思いますが、Sin波が扱えるだけでもかなり色々な表現が出来ることが分かったと思います。本記事がたくさんの作品を作る出発点になりましたら幸いです。