.NET (F#) でサウンドプログラミング (波形と音程と音量)


はじめに

前回はとりあえず音声のストリーム再生をできるようにしただけだったので、もう少し踏み込んでシンセサイザーを作ることからやっていこうと思います。シンセサイザーっていっても DTM で使うようなすごいやつではなく、本当に簡単なものからです (サイン波を作るだけでもシンセサイザーですし) 。

今回からは基本的に F# でやっていきます (C# もやりたい気持ちだけはある) 。レポジトリはこちらです。 libsoundio-sharp でストリーム再生をするユーティリティも F# で書き直しました。

上記リポジトリは libsoundio-sharp を submodule で持っていますので、 submodule を取得してから前回記事を参考にビルドしてください。

今回は

  • 任意の音程の音を発音する
  • それらを合成してドミソの和音を鳴らす

までやります。

プログラムで音を作る、とは

コンピューターで音を扱う場合、一般的に PCM という方式で扱います。他に DSD というものもありますが、一般的ではない (し自分もよくわかっていない) のでここでは扱いません。 DPCM, ADPCM は PCM の符号化方式が異なるもので本質的には同じです。

時間軸方向を任意の間隔で信号 (音) の強さを標本化 (サンプリング) して記録し、再現する時はその逆で信号の値を強さとしてスピーカー等に出力します。 "任意の間隔" は 1 秒間に何回行うか、という サンプリングレート で表します。一般的には 44100Hz とか 48000Hz が扱われます。

図は 2Hz の波をサンプリングレート 50Hz でサンプリング (標本化) したものです。どちらも単位は Hz ですが、扱う領域が違っていることがわかると思います。サンプリングレートと明記されてなければ通常は前者の波の周期 (ここでは音の高さ) を表しますので混同しないよう注意してください。

プログラムで音を生成する場合、 時間を入力値とし、出力を振幅とした関数 で表現が可能になります。実際は

  1. 時間 → 時間
  2. 時間 → 振幅
  3. 振幅 → 振幅

で 2. の関数の前に 1. の関数を n 個、 2. の後に 3. の関数を m 個並べる形になります。

単純な音を作っていく ("時間 → 振幅" 関数)

最初に音ネタとなる波形を作る必要があります。

サイン波

サイン波は sin 関数で生成する波形です。

LINQPad に下記コードを張り付けて実行してみてください。サイン波のグラフが表示されます。

let sampleRate = 48000

let wave t = Math.Sin(Math.PI * 2.0 * t)

let samplesToSec rate sample = (float)sample / (float)rate

let fn x = x |> samplesToSec sampleRate |> wave

let x = [0..sampleRate]
let y = x |> List.map(fn)
x.Chart().AddYSeries(y, Util.SeriesType.Line).Dump()

  • samplesToSec 関数でサンプル単位から浮動小数点の秒単位の時間に変換します。
  • wave 関数は 1 秒で一周期 (1Hz) の sin 値を信号として出力するようにします。

この二つをパイプライン演算子でつなぐとサンプル単位のリストからサイン波の出力を得ることができました。

矩形波

矩形波はサイン波よりもっと簡単で +1 と -1 を交互に繰り返すだけです。グラフが矩形に見えるので矩形波。チップチューンといえばこれでしょう。

先のサイン波のコードより wave 関数を次のものに差し替えます。

let wave t = 1.0 - round(float t) * 2.0

音の高さを変える ("時間 → 時間" 関数)

先の波形はサンプリングレート 48000Hz で 48000 サンプル = 1 秒分の長さに対して 1 周期の波形、でした。つまり 1Hz の波、ということになります。音の高さはこの振幅数が大きいほど高くなります。 2Hz の波を作りたいのであれば 2 倍速く進めれば時間あたりの振幅が 2 倍になるので、単純に wave 関数の入力値を 2 倍にすればよいわけです。つまり wave 関数の前に時間を入力し時間を出力する関数 を入れます。

let pitch n t =
    let x:float = n * t
    x - floor(x)

let fn x = x |> samplesToSec sampleRate |> pitch 2.0 |> wave

  • pitch 関数は基本的に入力時間を n 倍して出力します。が、後の波形生成の都合上 0.0 <= t < 1.0 で繰り返すように調整しています。

pitch 関数を wave 関数の前に入れることで音の高さが調整できるようになりました。

音の強さを変える ("振幅 → 振幅" 関数)

wave 関数が 時間を入力して信号を出力する ものなので、この後に 信号を入力して信号の大きさを調整して出力する 関数を追加します。

let volume rate value = rate * value

let fn x = x |> samplesToSec sampleRate |> pitch 2.0 |> wave |> volume 0.5
  • volume 関数は入力した信号値を n 倍して返します。

volume は信号値に対して処理をするので wave の後につなぎます。

ここまで全て適用すると次のようになります (波形はサイン波) 。

let sampleRate = 48000

let wave t = Math.Sin(Math.PI * 2.0 * t)

let samplesToSec rate sample = (float)sample / (float)rate  // 時間 → 時間
let pitch n t =                                             // 時間 → 振幅
    let x:float = n * t
    x - floor(x)
let volume rate value = rate * value                        // 振幅 → 振幅

let fn x = x |> samplesToSec sampleRate |> pitch 2.0 |> wave |> volume 0.5

let x = [0..sampleRate]
let y = x |> List.map(fn)
x.Chart().AddYSeries(y, Util.SeriesType.Line).Dump()

任意の音階の音にする

ここから少し音楽らしい事をします。音楽らしい音にするにはそれらのルールに則った音の高さを算出する必要があります。そのルールは二つ。

これらは絶対ではない (というか生楽器の場合は大抵違う) ですが、コンピューターで音楽をやる場合は大抵はこの二つに則っているはずです。

まず 440Hz という周波数ですが、これはピアノの真ん中あたりの "ラ" の音です。この音の周波数が全ての基準になります。

次に "平均律" ですが、これは 1 オクターブ内の 12 音を均等に 12 分割して割り当てるものです。

  • 1 オクターブ上がると周波数は倍になる。同様に 1 オクターブ下がると周波数は半分になる。
  • 平均律は 1 オクターブを 12 分割する。つまり半音上げるということは (2 の 1/12 乗) 倍したものということ。

これらに基づいて A4 を基準に半音 n 個分の高さが違う音の周波数を求める関数は次の通り。

let notePitch n = 440.0 * Math.Pow(2.0, float (n) / 12.0)

というのを元に音階から周波数を求めたり、時間 → 信号の変換をする便利クラスを作りました。

これを使って例えば C4 の高さになるようにするにはこれまで pitch 関数を入れていたところを次のようにします。

let fn x = x |> samplesToSec sampleRate |> Note(NoteName.c, 4).perCycle |> wave |> volume 0.5

和音にする

とりあえず任意の音階で波形を作れるように改変します。

let fn noteName octave x = x |> Note(noteName, octave).perCycle |> wave |> volume 0.3

音を合成するには単純に信号を加算すれば OK です。つまりドミソの和音の波形を生成する関数は

let fn2 x = (x |> (fn NoteName.c 4)) + (x |> (fn NoteName.e 4)) + (x |> (fn NoteName.g 4)))

となります。ということで サンプルプロジェクト を実行してみるとドミソの和音が再生されます。ここでは矩形波にしています。

let main argv = 
    [0..sampleRate * 2]
    |> List.map(fun x ->
        x
        |> samplesToSec sampleRate
        |> (fun x -> (x |> (fn NoteName.c 4)) + (x |> (fn NoteName.e 4)) + (x |> (fn NoteName.g 4)))
        |> float32)
    |> SoundIOOutStreamUtil.playsound sampleRate
    0

おわりに

音は

  • 音の元となる波形の生成
  • 波形の加工
  • 波形の合成

これらの処理を組み合わせで生成ができること、またそれぞれの関数は極々簡単なものでも組み合わせていくことで複雑な波形の生成が可能になることがわかりました。

音をコードで鳴らす仕組みを作っておくと、簡単なコードでいろいろ実験できるのでプログラミングの学習テーマとしても悪くないなあと思いました。

次回は LFO とエンベロープかなあと考えています。今回までは immutable で実装できていますが、 LFO やエンベロープは状態管理が必要になりそうでどうやって表現 (実装) しようか悩み中なところです。エンベロープができたら FM 音源も実装できそうですね。