音プログラムをつくる
先日(2019年10月ごろ)音プログラムをつくりました。
こいつ自体はCC0でリリースしてるのでソースコードから知識をどうぞパk参考にしてやってください。すごい音プログラムではないけど、こういうプログラムを作るときに考えることや知っておくといいことなどを記事として残しておきます。
どうでもいいけどロゴかわいいのつくれました。7月に生まれた東武動物公園のマレーバクの男の子アルタイルくんです。
- ステップ1: どんな音プログラムにするか考える
- ステップ2: ピーって鳴らす
- ステップ3: 音づくりの幅を広げる
- ステップ4: リズムに合わせて音を鳴らす
- ステップ5: ユニットの構成をテキスト言語で指定する
- まとめ
ステップ1: どんな音プログラムにするか考える
まずは仕様を固めましょう。
生成した音はリアルタイムにスピーカーで鳴らすのか、WAVファイル等に書き出す方式なのか。音の生成方法はどのように指定するのか。インタラクティブに音の生成方法を変更できるようにするのか。ユーザインターフェースはどうするのか。
まずはモチベーションを整理します。今回Altairをつくるに至った理由として、以下のような思いがありました:
- Nim version 1.0が出たのでなんかしたい
- 音プログラムにしたいけど音作りに凝れなくていい
- 自作言語で挙動(音色・メロディ等)を指定したい
- Forth/PostScriptはいいぞ
- しかしあまり時間をかけたくはない
- パラメータの時間変化をいれたいが、めんどくさそう
- 譜面データを自作言語で合成(繰り返しとか)したいが、めんどくさそう
- 自作言語を多機能にしたいが、めんどくさそう
- 音はリアルタイムで鳴ってほしい
- しかしインタラクティブにするとなにかしらのインターフェースを考える必要があってめんどくさそう
そこでAltairの場合は以下のような仕様にしました。
- 音はスピーカーから鳴らす
- クロスプラットフォームなサウンド入出力ライブラリPortAudioのNimラッパーを用いる
- 音の生成方法はPostScriptっぽい言語で指定する
- この言語は関数定義できないくらいの弱い言語にする
- 音の生成方法をインタラクティブに変更しない (=UIなし、マルチスレッディングなし)
- ただし、REPLで音の生成方法をまるごと挿げ替えることはできる
加えて、音の生成方法について以下のことを考えました。
- 音を出すオシレータは正弦波とノコギリ波とノイズくらい
- メロディ/リズムを指定するシーケンサはかなり簡単なもの
- モジュラーシンセみたいなモジュレーションは一切搭載しない
- パラメータは初回のみ指定でき、時間方向の変化はしない
- 再生時にシーケンサのループ再生はしない
- これは拍子情報を持つか持たないかの判断基準になる
- 今回は拍子情報を持たない
わりとシンプルなものになりそうです。
ステップ2: ピーって鳴らす
まずは音のhello worldということで、とりあえずピーと鳴らしてみます。
ちなみにこのAltairはNimという言語で書きました。先日version 1.0が出たのでうれしくって書きはじめたのがAltairなのでした。なので引用するコードは(過去のコミットログから引っぱりだしてくるので)Nimで記述されています。まあ雰囲気で読めるでしょう。
これがノコギリ波でピーしたときのプログラムです。
オーディオ入出力にはクロスプラットフォームなオーディオライブラリのPortAudioを用いることにしました。やっていることはだいたい以下のような感じです。
- 波形を鳴らすときの状態を保持するオブジェクトを定義する
- 上記オブジェクトを用いて信号を生成する関数を定義する
- オーディオライブラリを初期化する
- 信号を生成する関数をオーディオライブラリに設定する
これらを詳しく説明しつつ、オーディオライブラリの仕組みや使い方についても説明します。
オーディオライブラリの仕組み
コンピュータにおいて音をスピーカーから出すにはプログラムのパラメータやユーザの入力から最終的には電圧の変化を作りだす必要があります。しかしそのへんは各サウンドデバイスを含めてOSが、さらにはオーディオライブラリが抽象化してくれています。われわれがする必要があるのは
- 信号についての情報(サンプリング周波数など)を与えて
- 信号そのものを逐次ライブラリに渡してやる
ことです。
そのために、まず音がコンピュータ上でどのように扱われるのかを知る必要があります。音は連続なデータですが、コンピュータ上では離散的なデータで擬似的に表現します。連続な音を一定時間ごとにサンプリングし、そのサンプリングした数値の列として表現します*1。このとき音をサンプリングする時間間隔のことをサンプリング周波数といいます。CDだとサンプリング周波数は44.1kHzと決められています。サンプリング周波数はオーディオライブラリ初期化時に指定し、音が鳴っている間変化しない値です。
サンプリング周波数は時間方向に信号を離散化する際の情報です。コンピュータで離散的にオーディオデータを用言するには、サンプリングされた値を大きさの方向にも離散化(量子化といいます)することも必要です。値を何bitで量子化するかということで量子化ビット数といいます。オーディオライブラリにおいては、サンプル値を表現するのにどの型 (char, int, float, ...)を使うのかで指定します。Altairではsingle floatでサンプル値を表現しているので32bitです。
信号そのものをライブラリに渡す方法ですが、1サンプルずつ渡すのではなくある程度の量のサンプルをまとめて渡す方式がとられることが多いです。理由はサンプル計算から実際の音出力までのオーバーヘッドを少なくするためです。プログラムがサンプルを計算してからデバイスに渡るまでにオーディオライブラリ、OSによる仲介、デバイスドライバ……といろいろな抽象化レイヤー経て信号データがスピーカーに送られていくのですが、これを1サンプル毎にやっているとオーバーヘッドが大きくなり処理が追いつかなくなってしまうという寸法です。
上で示したhello worldコードではPA.OpenDefaultStream
手続きの引数でframesPerBuffer
として渡しているのがそのバッファのサイズです。
音生成のモデル
そこそこ複雑な音プログラムでは音生成の各部分がモジュールとして独立しており、これを組み合わせて複雑な音作りをする、というアプローチを取られることが多いです。音を波形を生成したり(オシレータ)、入力された波形を処理して出力したり(エフェクト)といった基本的なモジュールを多数用意し、それらを繋ぎ合わせるのです。この方式はユニットジェネレータと呼ばれています *2。
音を鳴らすプログラムでは状態の管理が重要です。
波を生成するオシレータではいま波のどの時点まで計算したか、という状態を持つ必要があります。ディレイやフィルタなど、入力を遅延させてごにょごにょする系のエフェクトでも、一定長の信号をバッファに入れておく必要があります。
このような状態管理は、オブジェクト指向のアプローチで実装するとうまくいきます。ユニットジェネレータにもオブジェクト指向的な側面があり、このアプローチで実装するとうまくいきそうです。
ユニットジェネレータ方式では複雑な音作りを実現するために、各ユニットジェネレータのパラメータ部分の値として別のユニットジェネレータの出力を使えるようにしてあるものがほとんどです。このようなやりかたをモジュレーションといいます。
今回Altairではユニットジェネレータ方式を採用しつつ、モジュレーションのような柔軟な音作りはしない方針で設計を行いました。
ノコギリ波の生成
ノコギリ波を生成します。ここだけ上のコードとは式が異なることに注意してください(floor
の方法を当時知りませんでした)。ノコギリ波とは以下の図のような形の波形です。
時間が0のとき値が最低点で、1周期すぎていく間に最高点まで値が上がっていく波形です。ここでは周期を1としておきましょう。そうすると、0から1までの間で、以下のような計算をすればよいです。
ここでfloor
は小数部を切り捨てる関数です。この関数の値域は0から1ですが、音の信号は(浮動小数点型においては)-1.0から1.0の間で表わされることが多いので値を調整すると、こうなります。
ユニットジェネレータの定義
さて、実際にユニットジェネレータを定義していきましょう。エフェクトを繋ぎたくなるかもしれないのでユニットジェネレータはとりあえず「前」があるように作っておきます(と書いたけれどけっきょく使ってませんでした)。値の計算のときには前の値をそのまま出力するようにしておきます。
type Unit = ref object of RootObj prev: Unit method procUnit(u: Unit): Signal {.base.} = return u.prev.procUnit()
次にこれを継承してノコギリ波オシレータ用のユニットジェネレータをつくります。オシレータなので「前の結果を流す」というUnit
の処理は呼びません。Saw
オブジェクトのphase
スロットがオシレータの進行状態 (位相角) を保持しています。
type Saw = ref object of Unit phase: float32 method procUnit(u: Saw): Signal = u.phase += 0.01 var ph = u.phase mod 1.0f32 s: Signal # 古いコードなのでfloorつかってない if ph <= 0.0f32: s = (1.0f32, 1.0f32) else: var v = -2 * ph + 1 s = (v, v) return s
あとはユニットジェネレータオブジェクトの初期化やオーディオライブラリの初期化、信号バッファを埋めるコールバックを書けば音が出るようになります。
音程を指定する
ここまでのコードは波形を出すことのみに集中していたので、音程のことは考えていませんでした。実際に音楽をつくるには、まず周波数によってオシレータの音程を指定できる必要があります。ここではオシレータユニットを定義しパラメータとして周波数を設定できるようにします。
音程は440Hzがラの音です*3。はじめは周波数を直接指定するとして、オシレータの内部状態の位相角を周波数に従い正しい値で更新できるようにします。
各サンプルの値を繰り返し計算することで信号を生成するため、信号の時間単位はサンプリング周波数です。このプログラムではサンプリング周波数を44.1kHzとしているため、1サンプル進むと1/44100秒が経過します。1Hzの信号を出力したい場合は44100サンプルで1周期進めばいいため、位相角の差分は1/44100です。2Hzのときは2 / 44100 = 22050サンプルで1周期すすめばよいため位相角は1/22050となります。nHzのときはn / 44100サンプルで1周期すすめばよいわけです。
したがって以下のように作っておくと任意の周波数でノコギリ波を鳴らすことができます。
# だいたい https://github.com/t-sin/altair/blob/71c6ed46663cbd36b665b2cda55d1d719ba7d3de/src/altair/ug.nim#L39 type Osc* = ref object of UG phase*: float32 freq*: float32 Saw* = ref object of Osc method procUG*(ug: Saw, mi: MasterInfo): Signal = var ph_diff = 440 / mi.sampleRate ph = ug.phase s: Signal var v = 2 * (ph - floor(ph)) - 1 ug.phase = ph + ph_diff (v, v)
ステップ3: 音づくりの幅を広げる
まずは音が出るようになりました。しかしとても理想的なノコギリ波なので音が細くて物足りないです。せめて倍音を豊かにしたりしたい。
そこで、信号同士の演算を実装して音をつくれるようにしてみようと思います。
現状は単音しか鳴らせません。そこで複数の音を同時に鳴らせるようにしましょう。複数の音を同時に鳴らすということは、オシレータ等の音を出すものが複数あってその出力が足し合わさればいいわけです。これをMix
ユニットとして実装しているのがこのコミットです。
このコミットの中ではデチューン技が使われています。2つのオシレータの周波数 (=音程)をわずかにずらすことでうなりを発生させ、1つのオシレータで鳴らすより広がりのある音をつくることができます。
var saw1 = Saw(phase: 0, freq: 440) saw2 = Saw(phase: 0, freq: 445) mix = Mix(sources: @[saw1.UG, saw2.UG], amp: 0.2)
Mix
ユニットでは信号を足し算しました。たとえば2つの信号を掛け算するとどのような音になるでしょうか。これは振幅変調 (AM; amplitude modulation) あるいはリング変調 (ring modulation)と呼ばれる方法です*4。
以下のコミットでは(後で説明するエンベロープジェネレータも入っていますが)信号を掛け合わせるMul
ユニットを実装しています。
ユニットは以下のように結線されています。
var saw1 = Saw(phase: 0, freq: 440) # こいつは後で登場します(エンベロープジェネレータです) env = Env(adsr: Release, eplaced: 0, a: 0.1, d: 0, s: 1, r: 0.8) mul = Mul(sources: @[env.UG, saw1.UG]) mix = Mix(sources: @[mul.UG], amp: 0.2)
ちょっとバリバリとした、素のノコギリ波とは違った音が鳴るようになりました。
このように、複数の信号を影響させることで単純な波形からより複雑な音を得ることができるのです。
ステップ4: リズムに合わせて音を鳴らす
リズムに合わせて音を鳴らすとはどういうことでしょうか。オシレータは常に音を発しつづけていますから、音を鳴らしたり止めたりということを表すユニットを別途実装する必要がありそうです。音の再生や停止は、オシレータの音の音量をコントロールすることでもあります。プログラムの中で時間をカウントしておいて、時間が音を鳴らすタイミングになったら音量を上げ、音を停止するタイミングになったら音量を下げることで音のオンオフを実現できます。
音量変化については別の側面もあります。
音量変化がゆっくりだとやわらかい音に、音量変化が速いと鋭い音になります。音色というのは周波数の分布だけでなく音量変化にも依存しているのです。音量の変化のみを表す0から1までの信号を用意してMul
ユニットでオシレータの出力に掛け合わせれば実現できそうです。
整理します。リズムに合わせて音を鳴らすには次の3つを導入・実装しておく必要があります。
- 時間の概念
- 音量変化を表す信号
- イベントキューとスケジューリング
順番に見ていきましょう。
時間の概念
これから時間に関する諸々を設計するのでまずは時間の概念をプログラムに導入します。
# https://github.com/t-sin/altair/commit/4300fbd16af07d307ae6a0ab2fe354921e8f287c Signal* = tuple[left: float32, right: float32] MasterInfo* = ref object sampleRate*: float32 + tick*: uint64 proc `*`*(s: Signal, v: float32): Signal = (s.left * v, s.right * v)
このコミットで、プログラム全体の情報の中に「プログラム開始時から経過したサンプル数」をtick
という名前で追加しました。サンプリング周波数は音を鳴らしている間は変えられないため時間の指標に用いることができます。1サンプル経過すると1/44100秒が経過する、というわけです。
あとはPortAudioのコールバック関数内のサンプル毎の処理でこの変数をインクリメントしてやれば、その時点の時間を知ることができるようになります。以下はサンプル毎にPortAudioに渡すコールバック関数のコードです。この中で2つ後の節ででてくるイベント処理(シーケンサ)の更新と出力サンプル値の計算、時間情報の更新を行っています。
# https://github.com/t-sin/altair/commit/555b005a01596011a7718f23ca053ae018c17e60 # 開発時のものなのでもうシーケンサが登場してますが最後に説明します for i in 0..<framesPerBuf.int: - outBuf[i] = procUG(soundsystem.rootUG, soundsystem.masterInfo) + for ev in soundsystem.events: + procSeq(ev, soundsystem.mi) + outBuf[i] = procUG(soundsystem.rootUG, soundsystem.mi) + soundsystem.mi.tick += 1 + soundsystem.mi.sec += 1.0 / soundsystem.mi.sampleRate scrContinue.cint
soundsystem
は自前のユニットジェネレータ達やイベントスケジューラを持っているオブジェクトです。mi
はmasterInfo
というオブジェクトで、Altairのサンプリング周波数等再生時の情報や時間進行情報を格納するオブジェクトです*5。
音量変化を表す信号
リズムに合わせて音を鳴らすには音のオン/オフの概念を導入する必要があります。音符を鳴らすタイミングでオシレータの音量を上げ、音符が終わったタイミングで音量を下げるのです。この音のオン/オフを一般化したADSRエンベロープという以下の図のような音量制御モデルがよく使われています。
ADSRとはAttack, Decay, Sustain *6 , Releaseの略で、音の立ち上がり時間 (attack)、最大値からの減衰時間 (decay)、持続音の音量 (sustain) と0までの減衰時間 (release)の4つのパラメータで音量を制御するものです。たとえばアタックが短いと鋭い音になり、アタックが長くなると音がやわらかくなります。
ADSRを実装するために以下のような構造体を用意します。
# 覚え間違いにより実際のソースコードでは以下の2つのスペルミスがある # - SustainをSustinと書いてしまっている # - elapsedをeplacedと書いてしまっている type ADSR* = enum None, Attack, Decay, Sustin, Release Env* = ref object of UG adsr*: ADSR eplaced*: uint64 a*: float32 d*: float32 s*: float64 r*: float32
a
、d
、s
、r
はよいとして、adsr
は現在どの状態にあるかを保持しています。またelapsed
は音符のオンになってからの時間です。
これらをどう処理するかというと以下のように分岐の力技でゴリっと処理します。Env
は外部からadsr
の状態とelapsed
を更新されると、自身の状態を更新しながら別の信号に掛けられる想定の(つまり音量を変化させる)信号——0から1までの値——を出力します。adsr
の各状態によって分岐させ、その中でさきほどの図にあったようなある時間における音量値を決定して返します。
# 覚え間違いにより実際のソースコードでは以下の2つのスペルミスがある # - SustainをSustinと書いてしまっている # - elapsedをeplacedと書いてしまっているmethod procUG*(ug: Env, mi: MasterInfo): Signal = var a: uint64 = (ug.a * mi.sampleRate).uint64 d: uint64 = (ug.d * mi.sampleRate).uint64 s: float32 = ug.s r: uint64 = (ug.r * mi.sampleRate).uint64 v: float32 if ug.adsr == Attack: if ug.eplaced < a: v = ug.eplaced.float32 / a.float32 elif ug.eplaced < a + d: v = 1.0 - (1.0 - s) * ((ug.eplaced - a).float32 / d.float32) ug.adsr = Decay else: v = ug.eplaced.float32 / a.float32 ug.adsr = Decay elif ug.adsr == Decay: if ug.eplaced < a + d: v = 1.0 - (1.0 - s) * ((ug.eplaced - a).float32 / d.float32) elif ug.eplaced >= a + d: v = s ug.adsr = Sustin else: v = 0.0 ug.adsr = None elif ug.adsr == Sustin: v = s elif ug.adsr == Release: if ug.eplaced < r: v = s - ug.eplaced.float32 * (s / r.float32) else: v = 0.0 ug.adsr = None else: # None v = 0.0f32 ug.eplaced += 1 (v, v)
このエンベロープを用いて以下のようにすると0.1秒かけて音が立ち上がります。音符のオフを入力していないため音はそのまま鳴り続けます。
var saw1 = Saw(phase: 0, freq: 440) env = Env(adsr: Release, eplaced: 0, a: 0.1, d: 0, s: 1, r: 0.8) mul = Mul(sources: @[env.UG, saw1.UG]) mix = Mix(sources: @[mul.UG], amp: 0.2)
自動で音符オフをする処理も含め、譜面情報に従って音をオンオフする処理を次の節で実装します。
イベントのスケジューリング
次に、事前に定義された譜面データにしたがってオシレータの音程とエンベロープジェネレータの内部状態を更新していく処理を定義します。まずはデータ構造を考えましょう。シーケンサーの導入です。
type Note* = tuple[freq: float32, sec: float, adsr: ADSR] type EV* = ref object of RootObj type Seq* = ref object of EV osc*: Osc env*: Env pat*: seq[Note] idx: int
シーケンサーは入力としてオシレータとエンベロープジェネレータを受け取ります。エンベロープジェネレータの出力 (0から1)とオシレータの出力 (-1から1)を掛け算し音量制御とします。エンベロープジェネレータの内部状態の更新に用いるのがpat
とidx
です。pat
はNote
というイベントのリストです。Note
は周波数と時間とADSRの状態で表わされるオブジェクトです。再生中に現在の時間がpat
内の時間を超えるとNote
イベントが処理され、そうでなければNote
イベントの時間がくるまで待つ(イベントを処理しない)というわけです。
ここで時間の概念を導入したときに登場した、サウンドバッファを埋めていくコードをもういちど見てみましょう。
# https://github.com/t-sin/altair/commit/555b005a01596011a7718f23ca053ae018c17e60 for i in 0..<framesPerBuf.int: - outBuf[i] = procUG(soundsystem.rootUG, soundsystem.masterInfo) + for ev in soundsystem.events: + procSeq(ev, soundsystem.mi) + outBuf[i] = procUG(soundsystem.rootUG, soundsystem.mi) + soundsystem.mi.tick += 1 + soundsystem.mi.sec += 1.0 / soundsystem.mi.sampleRate scrContinue.cint
soundsystem.events
というのが2つ上のコードで導入したEV
型です。EV
型はイベント処理系オブジェクトのルートオブジェクトですが、その子の一つとしてシーケンサオブジェクトSeq
があります。Seq
型のseq
というスロットには譜面パターンがNote
の配列として保持されており、このうち何処にいるかを表すidx
を更新する処理がprocSeq
なわけです。以下のコミット
ではシーケンサのためのオブジェクトと補助関数を定義しています。だいたい音符の長さ(四分音符とか)からイベントを処理する時刻を計算する関数とか、音名(cとかdとか)から周波数を計算する関数とか、そういう雑多な処理が定義されています。これで、事前に定義した譜面を用いて音を鳴らしたり止めたりすることが可能になります。
というわけでこれら3つを結合しバグを取った結果、以下のようなコードが動くようになりました。
# https://github.com/t-sin/altair/tree/d6683509c27eaba3bc868599b4768f623f5a47ab var saw1 = Saw(phase: 0, freq: 440) rnd = Rnd(phase: 0, freq: 0) var env = Env(adsr: Release, eplaced: 0, a: 0, d: 0.04, s: 0.1, r: 0.2) rhythm = Seq(env: env, osc: rnd.Osc, pat: len_to_pos(120, @[2,-1,1,1,1,1,1,2,2,2,2])) var mix1 = Mix(sources: @[saw1.UG, saw2.UG], amp: 1) mul = Mul(sources: @[env.UG, rnd.UG]) mix2 = Mix(sources: @[mul.UG], amp: 0.2)
動かしてみるとスーパーなノコギリ波がリズムに合わせて音が鳴ったり止まったりしているようすが確認できます。
ステップ5: ユニットの構成をテキスト言語で指定する
ここまでくるとユニットジェネレータのパラメータをいろいろにいじることでわりといろんなことができるようになります。しかしなんというか、いまのままではプログラムをコンパイルし直さないと挙動を変えることができなくて非常に面倒です。せめてこのユニットジェネレータの状態をユーザインターフェースかなにか、あるいはテキストファイルとかでDSLで指定できれば…。そんなわけでここではDSLとしてForthっぽい言語を設計し実装してみました、というのがこちらです。
名付けてTapir Forthです!!
Forthというのはスタック指向プログラミング言語あるいは連鎖性プログラミング言語 (concatenative programming language)として有名なあのForthですが、それっぽい言語を実装して、内部のユニットジェネレータを指定できるようにしました。これにより、以下のコードでドレミファソラシドを鳴らすことができす。
440 saw dup .s 0 0 1 0.1 adsr dup .s rot swap .s % push sequence pattern and make sequencer (not `rseq`) () :c 3 3 n :d 3 3 n :e 3 3 n :f 3 3 n :g 3 3 n :a 3 3 n :b 3 3 n :c 4 3 n seq () swap append .s ev () swap append swap append .s mul ug
まとめ
ちょっと駆け足でしたが音プログラムの設計から実装の流れをざっくりと解説してみました。この程度の簡単なもの(低レベルIOをしていない、機能をとても絞っている)ならわりとささっと実装できてしまうので、みなさん春休みの自由研究として音プログラムづくりに挑戦してみてはいかがでしょうか。
ちなみに最近、ここ一年くらいつくっていたRust製音楽ソフトウェアKotoが完成したのでついでに宣伝しておきます。ファイルシステムをインターフェースとする、ライブパフォーマンス環境です。
*1:このあたりはWikipediaの「パルス符号変調」の記事などを参照のこと。
*2:英語版Wikipediaの"Unit generator"の記事を参照。
*3:時報のピッピッピッポーンはピッの部分が440Hzのラの音でポーンの部分は880Hzの1オクターブ高いラの音。
*4:ちなみにぼくはリング変調と振幅変調の違いがよくわかりません。前者はハードウェア的な観点で、後者は理論的な視座に立ったて見たときのもの…?
*5:DAWとかのコードを後で読んだところ曲の進行状況オブジェクトにはtransportという名前をつけるのが一般的だったっぽい
*6:ちなみに今日2020-03-05までsustinだと思っていました