音の理屈と実践

この記事はDXRuby Advent Calendar 2013の14日目です。
13日目の記事はあおいたくさんのならせる!SEでした。

まさかのSoundEffect記事。ピコピコ音を生成する機能だが、音の原理や波形のイメージが頭の中に無いと地味に理解しづらい機能でもある。記事では言葉の端々にそのあたりの知識が見え隠れする感じで、あおいたく氏は理解できてそうだが、読んだ人が挙動を理解できたかというとちょっと怪しいかもしれない。まあ、そのへん興味があるならネット上にいくらでも解説記事が出ているので検索してみるといいだろう。わかってくるとたぶんSoundEffectが楽しくなる。

14日目のこの記事では、もうちょっと突っ込んだ音の理屈の雰囲気の説明と、SoundEffectを使ってそれを体験してみることにする。なんとなくわかった気になって頂ければ幸いである。ほんとは音関連の機能の拡張予定ネタとか考えていたのだが、未定の事項が多すぎて微妙だった。

似たようなものがありました

いきなりだが、Rubyには地味にruby-soundwaveというライブラリがあって、wavファイルの読み書きおよび波形の生成、合成などができる。再生機能は無い。
https://github.com/koki-h/ruby-soundwave
DXRubyのSoundEffectはその場で再生できるが保存ができない。一長一短である。保存機能は作ろうと思っている。

音と周波数について

人間が聞く音には2種類あって、ざっくり言うと音の高さがあるものと無いものとなる。音の高さがあるものは音の波形が周期的に繰り返されていて、この周波数が音の高さとなり、波形が音色となる。人間の声とか管楽器や弦楽器の音などである。音の高さが無いものは周期的に繰り返されない波形で、打楽器や風の音などのノイズ系となる。
SoundEffectが生成する波形は引数で周波数を指定するように、音の高さがある周期的な波形である。が、一部のサンプルで作っているようにその周波数をランダムに細かく変化させてやることでノイズを作ることもできる。
音の波形は最も基本のものが正弦波で、時間の経過によりマイナスとプラスを行ったりきたりする。この値は空気を押したり引いたりする振動を表していて、振動が耳の鼓膜に伝わったときに音として感じられる。押したり引いたりする振動が周期的に繰り返されたとき、人間はそれを音の高さとして認識する。

フーリエさん

高さを持つ音は波形が周期的に繰り返されるわけだが、このときの波形は正弦波の合成として表現できる。とフーリエさんが言っていた。ある音の複雑な波形を、複数の正弦波に分割する計算をフーリエ変換と言う。
例えば矩形波はどう見ても正弦波ではないが、元の音を奇数倍した周波数を無限に合成してやれば綺麗な矩形波を作ることができる。つまり矩形波フーリエ変換すると無限の周波数の集合になってしまうわけで、分割された正弦波を合成すれば元に戻るのかもしれないが、計算量的に無理ゲーである。実際には矩形波をそれっぽく作るには正弦波を15個〜50個ぐらい合成してやれば問題ないレベルにはなる。

倍音というもの

ある周波数の正弦波をベースに、その周波数を整数倍した周波数を倍音と言う。例えばピアノの音なども、ベースになる正弦波+無数の倍音で成り立っている。どの周波数がどのボリュームで合成されているかにより色々な音色になる。また、世の中の楽器の音はキッチリ整数倍ではなくちょっとズレていたりして、合成で作ろうとしても簡単に作れるものではない。
人間の声も同様に倍音の集合である。「あ」という発音について、誰が言っても「あ」に聞こえるのは、そこに含まれる倍音が「あ」の特徴を持つからであり、この周波数分布の特徴をフォルマントと言う。この情報を元に「あ」の特徴を持つ波形を合成してやれば「あ」に聞こえるような音を作ることができる。

ということで

「あ」を合成してみよう。と言いたいところだったのだが、やってみたらなかなかうまくいかなかったので倍音を実験できるプログラムを作ってお茶を濁すことにした(ごめんなさい)。

スライダーはマウスで掴んで動かすことができる。16本あり(画像はちょと古くて10本しかないが)、一番左をベースの音(基音と呼ぶ。このプログラムではこれが220Hz)で、右に行くと2倍、3倍と周波数が上がっていく。一番右は16倍だ。高さはボリューム、上がMAXとなる。通常の音では高い倍音になるほどボリュームはぐっと下がるものだが、このプログラムでは見たままのレベルで合成されるようになっている。基音が一番大きくないとその高さに聞こえないので、そのへんは自分で調整するように。ていうかそもそも一番大きいから基音と言うのだ。
Zを押すとその設定で波形が合成され、1秒ぶんの音が再生される。Zを連打しながらスライダーを動かしてみると、どのへんの倍音をどれだけ含む場合にどんな音になるかを体験することができる。ソースは最後に貼っておく。
このプログラムではエンベロープを作っていないので平坦な音だが、そのへんを指定できるようにするとか、周波数ごとに別のエンベロープを指定できるようにするとか、そういうことができればもっと多彩な音を作ることができるのではないかと思う。
とか言ってもこんな面倒なことして効果音作るとか無いわー。

SoundとSoundEffectの実装など

ついでにちょっと書いておくと、SoundはDirectMusic、SoundEffectはDirectSoundを使っている。SoundのほうはDirectMusic任せなので多重再生が可能だが、サウンドバッファをロックできないので再生以外に何もできない。SoundEffectは単発のバッファを作って編集・再生するので多重再生ができない。
SoundEffectのほうはサウンドバッファをPCMで持ちメモリを食うが、いずれバッファ内の情報を参照することができるようにしたい。これで波形を画面に表示したりできるようになる。ひょっとしたら波形を直接編集できるようにもするかもしれない。んでwav出力もしたい。やる気と技術がある人ならRubyでエフェクトかけたりできるかもしれないし、シンセ的なものが作れるようになるかもしれない。
将来的にはSoundを再実装してogg対応や、Sound#to_seとかのメソッドでSoundEffectオブジェクト化するとか、SoundEffectの多重再生に対応するとか、そんなことができるといいなあーと思っている。

おしまい

あんまりDXRuby関係ない感じになってしまったが、SoundEffectはこういう音の合成ができるというサンプルである。倍音の合成は本質的に和音と同じであり、調整によって別々の音が同時に鳴っているように聞こえることもあるし、音色が変わったように聞こえることもある。音の不思議な面を垣間見せてくれる。
音関連はもうちょっと機能拡張して面白いことができるようにしてもいいなと思っているので、そのうち何かやると思う。

以上。15日の記事は初登場の鳴海つかささんです。お楽しみに〜。

require 'dxruby'

# スライダ
class Slider < Sprite
  attr_reader :data

  @@image = Image.new(11, 20, C_WHITE)

  def initialize(x, data)
    super(x, 150, @@image)
    @data = data
  end

  # 外部からyをセットする。50〜150の間に制限される。
  def set_y(y)
    y = 150 if y > 150
    y = 50 if y < 50
    self.y = y
  end

  # 移動範囲を表す線と一緒に描画する
  def draw
    Window.draw_line(self.x + 5, 50, self.x + 5, 169, C_WHITE)
    super
  end

  # スライダが表すレベルを0〜100で返す
  def level
    100 - (self.y - 50)
  end
end

f = 220
# スライダを16本作る(引数はx座標と周波数)
ary = Array.new(16){|i| Slider.new(i * 30 + 20, f * (i + 1))}

# 衝突判定用Sprite
col_point = Sprite.new(0, 0)
col_point.collision = [0, 0]

# マウスで掴んでいるSliderが格納される変数
mouse_drag = nil

# 前フレームのカーソル位置が格納される変数
old_x, old_y = 0

# SoundEffectオブジェクト
se = nil

Window.loop do
  # メソッド名が長くて打つのが面倒なのでローカル変数に入れる
  mouse_x, mouse_y = Input.mouse_pos_x, Input.mouse_pos_y

  if Input.mouse_push?(M_LBUTTON)
    # 衝突判定用Spriteに座標セット
    col_point.x, col_point.y = mouse_x, mouse_y

    # Sliderとの判定。マウスで掴んだSliderがmouse_dragにセットされる
    mouse_drag = col_point.check(ary)[0]
  end

  # ボタンを離したときはmouse_dragをクリアする
  mouse_drag = nil if !Input.mouse_down?(M_LBUTTON)

  # Sliderを掴んでるときのマウス移動
  if mouse_drag
    # Sliderを動かす
    mouse_drag.set_y(mouse_drag.y + mouse_y - old_y)
  end

  # 描画
  Sprite.draw(ary)

  # 1秒間の音を生成して再生
  if Input.key_push?(K_Z)
    # とりあえず止める
    if se
      se.stop
      se.dispose
    end

    # ボリューム調整用係数算出
    tmp = ary.inject(0) {|a, b| a + b.level}
    if tmp <= 255
      v = 1
    else
      v = 255.0 / tmp
    end

    # スライダにより指定された倍音を含む音を生成する
    se = SoundEffect.new(1000, WAVE_SIN) {[ary[0].data, ary[0].level * v]}
    ary[1..-1].each do |a|
      se.add(WAVE_SIN) {[a.data, a.level * v]}
    end
    se.play
  end

  # 古いカーソル位置を保存する
  old_x, old_y = mouse_x, mouse_y

  break if Input.key_push?(K_ESCAPE)
end