/dev/dsp で音声を鳴らす方法

超絶技巧 Ruby プログラミングの質疑で「どうやって音を鳴らしているのか」という質問があったので、自分のための記録を兼ねて簡単に紹介。
といっても Linux Sound programming with OSS API にある通り。Ruby で書くとこんな感じ。

# デフォルトでは 8bit 8000 Hz
SampleSize = 256
SamplingRate = 8000

# ... 0:ラ 1:ラ# 2:シ 3:ド 4:ド# 5:レ 6:レ# 7:ミ 8:ファ 9:ファ# 10:ソ 11:ソ# 12:ラ ...
tone = 3

# ボリューム: 0 〜 SampleSize/2 まで
volume = 60

# 再生する長さ: 秒
length = 2

# 周波数: 基準のラは 440Hz 、1 オクターブ上がると倍になる
freq = 440 * 2**(tone / 12.0)

open("/dev/dsp", "wb") do |f|
  phase = freq.to_f / SamplingRate
  wave = (0 ... length * SamplingRate).map do |i|
     Math.sin(2 * Math::PI * phase * i) # 正弦波
    #(1 - 2 * ((phase * i) % 1))        # ノコギリ波
  end
  wave = wave.map {|v| (SampleSize / 2 + volume * v).round }
  f.print(wave.pack("C*"))
end

ノコギリ波は倍音がいっぱい混ざった波形なので、バイオリン系の豊かな音になるらしい。
以下は超絶技巧 Ruby プログラミングで使った音楽 (G 線上のアリア) を再生するコード。ろくに清書してないので汚い。あと変数名 (score とか tone とか) は定義をよく調べないで使ってるので当てにしないこと。あとこれは 8000 Hz なので音質が悪い。発表では、サンプリングレートを 44100 Hz に上げていた。そのためには ioctl を呼ぶ必要があって、そのためには sys/soundcard.h とかで定義されている SOUND_PCM_WRITE_RATE の値が必要で、そのために gcc を呼んでいたりする。

Tone = {
  "a"  =>  0, "a+" =>  1, "b" =>  2, "c"  => -9, "c+" => -8, "d"  => -7,
  "d+" => -6, "e"  => -5, "f" => -4, "f+" => -3, "g"  => -2, "g+" => -1,
}

def mml_compile(mml)
  scores = ["", "", "", ""]
  mml.each_line do |line|
    next unless line =~ /^(\d+):/
    scores[$1.to_i - 1] << $'
  end
  seqs = scores.map { [] }
  scores.zip(seqs, [2, 2, 0, -1]) do |score, seq, octave|
    score.split.join.scan(/([a-gr<>,]\+?)([\d\-+]+)?/) do |tone, length|
      length = eval(length.gsub(/\d+/) { 32 / $&.to_i }) if length
      case tone
      when ","
      when ">"; octave += 1
      when "<"; octave -= 1
      when "r"; seq.concat([nil] * length)
      else seq.concat([octave * 12 + Tone[tone]] * length)
      end
    end
  end
  seqs
end

seqs1 = mml_compile(<<END)
1:d1                     ,d4<b4>c+4-32<b32a4       ,>a2a16f+16c16<b16>e16d+16a16g16,
2:f+1                    ,f+8b16g16e16d16c+16d16<a2,a8>c16<b16>c8a16c16<b8>r8r4    ,
3:d8>d8c+8<c+8<b8>b8a8<a8,g8>g8g+8<g+8>e2          ,e8d+4e8f+8r8r4                 ,
4:a2b2                   ,<b4>e4<a8>a8g8<g8        ,f+8>f+8e8<e8d+8>d+8<b8>b8      ,

1:g2g16e16<b16a16>d16c+16g16f+16,<a2a8g+16a16b8g+8                 ,a8a4g+8e2>                          ,
2:<b8>e16d16e16f+16g16e16<a8r8r4,>f+4f+8g+16a16d8d32e32f+8e16e16d16,c+16<b16b32>c+32d16d8c+16<b16a2>    ,
3:e8b4e8e8r8r4                  ,d4d8e8f+8d8b8e8                   ,e8f+8b8e8c+2                        ,
4:<e8>e8d8<d8c+8>c+8<a8>a8      ,d8>d8c+8<c+8<b8>b8g+8e8           ,a8d8e8<e8a16b16>c+16d16e16g16f+16e16,
END

seqs2 = mml_compile(<<END)
1:c+4+16d32c+32<b32>c+32<a16>a4a8c8,<b8>b8+16a16g16f+16g4+32f+32e32d32c+16<b16,
2:<a2+16b16>c8+16<b16a16g16        ,f+4+8b8e8>e8d8<d8                         ,
3:e2e8d+16e16f+4                   ,f+16g16a16g16d+8>d+8e2                    ,
4:<a8>a8g8<g8f+8>f+8e8<e8          ,d+8>d+8f+8<b8>b4<b4>                      ,

1:a+16b16>c+8+16d16e8+16f+16g4f+8           ,e16d16c+16<b16>c+16d32e32d8<b2,
2:c+8>c+8<b8<b8a+8b8>c+8<a+8>               ,<b8>g8e8f+8<b8>b8a8<a8>       ,
3:e2+16d16c+16<b16a+16b16>c+8               ,<b8b8b8a+8f+2                 ,
4:c+16d16e16f+16g16f+16g16e16f+8e16d16c+8f+8,f+8e16d16g8f+16e16d2          ,

1:>d4+16f+16e16d16b4+8a16g+16,f+32e32a16<a4g+8a2              ,a8b16>c16<b16>c+16d8+8c+16<b16>c+16d+16e8,
2:e4f+4<b8>e16f+16g+16a16b8  ,b8a8b8+16>c+32d32c+8+16<b16a4   ,>d4+8f+16e16e4+8g16f+16                  ,
3:<b8>b8a16g+16a8g+8+16f+16e4,e8e8f+8e8e8+16d16c+16d16f+16c+16,<f+8>f+8g8<g8g+8>g+8a8<a8                ,
4:<g+8>g+8f+8<f+8e8>e8d8<d8  ,c+8>c+8d8e8<a8>a8g8<g8          ,a8>d4<b8+8>e4c+8                         ,

1:e8d+16c+16d+16e16f+8g2     ,<a4+16>c+16e16g16g16e16f+8+8+16g32a32,d4+16f+16a16>c16<b4+8d8,
2:f+4+8a16g16r16d+16e16<b16e4,e16c+16e16a16>c+8<a8+8>c+16d16<d4    ,d8e8f+4d2              ,
3:a+8>a+8b8<b8>e8>e8d8<d8    ,a8g8f+8e8d4a4                        ,a8g8a4g2               ,
4:c+8f+4d+8<b4+16>b16g16e16  ,c+8>c+8<a8>c+8d8<d8c8>c8<            ,b8<b8a8>a8g8<g8f+8>f+8 ,

1:c+16e16g4d8<a8>e16f+32g32+16f+8e16,d32c+32<b8>c+16d8c+16d16d2,
2:e16<b16>e16g16b16a16g16f+16e8a4g8 ,a4g16f+16g8f+2>           ,
3:g8b8>e4+16d16c+16<b16a8b8         ,f+4e8a8a2                 ,
4:e8<e8d8>d8c+8<a8>d8g8             ,a8g8a8<a8d2               ,
END

seqs = seqs1.zip(seqs1, seqs2, seqs2).map {|seqs| seqs.flatten }
seqs[3][370,14] = [-24] * 14;
seqs = seqs.transpose

def play(rate, seqs)
  unit = rate * 10000 / 44100
  seqs.each_with_index do |tones, w|
    wave = [128] * unit
    tones.each do |tone|
      next unless tone
      tone = 440.0 / rate * 2**(tone / 12.0)
      unit.times {|i| wave[i] += 10 - 20 * ((tone * (w * unit + i)) % 1) }
    end
    yield wave.pack("C*")
  end
end

if true
  # output to /dev/dsp
  open("/dev/dsp", "wb") do |f|
    play(8000, seqs) do |s|
      f << s
      f.flush
    end
  end
else
  # generate air.wav
  header = ["WAVEfmt ", 16, 1, 1, 44100, 44100, 1, 8].pack("A8VvvVVvv")
  data = ""
  play(44100, seqs) {|s| data << s }
  data = ["data", data.size].pack("A4V") << data
  open("air.wav", "wb") do |f|
    f << ["RIFF", header.size + data.size].pack("A4V") << header << data
  end
end

ちなみに最後の if 文を false にすると 44100 Hz モノラルの wav が出力されます。無圧縮の wav はとても簡単ですね。