Common Lispで音ファイルを読み込む

(2022-12-04追記:cl-wavを便利に使える関数を作りました→Common Lisp用のwavread - 丸井綜研

Lispで音をやろうと思ってもなかなか情報が出てこないのですが、OpenMusicのような大規模なシステムもCommon Lispで書かれているので無理なことはないはず。Google検索してみると、masatoiさんが音声合成関係の記事をいくつか書かれていたり、t-sinさんが発表スライドを公開されていたりします。

www.slideshare.net

このスライドの中で紹介されていたpukunuiというシンセサイザーソースコードを読んでみたところ、cl-wavcl-portaudioというライブラリがあるのを知ることができました。音ファイルの読み書きと録音再生ができれば、あとはやりたいこと次第でなんとでもなりそうです。

今日は音ファイルの読み込みをやってみます。

cl-wavを試す

WAVファイルの読み込みをするために、cl-wavを試してみます。エレキベースのライン出力を録音した30秒くらいのファイルがあるので、それを読み込みます。

(ql:quickload '(cl-wav)

(setq snd (wav:read-wav-file "EBass_E1.wav"))

このsndというリストには((riff) (fmt) (data))という3つのリストが入っていて、RIFFファイルのチャンク構造がそのままになっています。そのため、firstsecondthirdを使って表示してあげると以下のようになります。特に2番目のfmtチャンクに音のメタデータが入っていて、今回のファイルはチャンネル数(NUMBER-OF-CHANNELS)が1、標本化周波数(SAMPLE-RATE)が48000 Hz、ビット数(SIGNIFICANT-BITS-PER-SAMPLE)が16だと読み取れます。

(setf *print-lines* 10) ; limit the number of lines to be displayed in REPL

(first snd)                             ; RIFF chunk
;;=> (:CHUNK-ID "RIFF" :CHUNK-DATA-SIZE 2880036 :FILE-TYPE "WAVE")

(second snd)                            ; fnt chunk (where all information are)
;;=> (:CHUNK-ID "fmt " :CHUNK-DATA-SIZE 16 :CHUNK-DATA
;;    (:COMPRESSION-CODE 1 :NUMBER-OF-CHANNELS 1 :SAMPLE-RATE 48000
;;     :AVERAGE-BYTES-PER-SECOND 96000 :BLOCK-ALIGN 2 :SIGNIFICANT-BITS-PER-SAMPLE
;;     16))

(third snd)                             ; data chunk
;;=> (:CHUNK-ID "data" :CHUNK-DATA-SIZE 2880000 :CHUNK-DATA
;;    #(0 0 0 0 0 0 1 0 255 255 1 0 0 0 255 255 3 0 253 255 5 0 251 255 5 0 253 255
;;      3 0 0 0 0 0 1 0 2 0 254 255 5 0 253 255 3 0 0 0 1 0 2 0 1 0 0 0 1 0 2 0 0 0
;;      3 0 0 0 1 0 3 0 255 255 3 0 1 0 1 0 2 0 2 0 1 0 2 0 0 0 4 0 0 0 3 0 1 0 1 0 ...
;; ずらずらとバイト列が出てきます

dataチャンクはバイト単位の列のようです。fmtチャンクの情報を使って、自力で16ビット単位や24ビット単位にまとめて行くしかないのでしょうか。WAVはリトルエンディアンだという知識も必要です。さいわい、cl-wavのREADME.mdには例として以下のような読み込み方法も書かれていました。chunk-data-readerに読み込み方を指定すれば、-1~+1の範囲に正規化してくれるようです。

CL-USER> (wav:read-wav-file "c:/windows/media/ding.wav" :chunk-data-reader (wav:wrap-data-chunk-data-samples-reader))
((:CHUNK-ID "RIFF" :CHUNK-DATA-SIZE 70052 :FILE-TYPE "WAVE")
 (:CHUNK-ID "fmt " :CHUNK-DATA-SIZE 16 :CHUNK-DATA
  (:COMPRESSION-CODE 1 :NUMBER-OF-CHANNELS 2 :SAMPLE-RATE 44100
   :AVERAGE-BYTES-PER-SECOND 176400 :BLOCK-ALIGN 4 :SIGNIFICANT-BITS-PER-SAMPLE
   16))
 (:CHUNK-ID "data" :CHUNK-DATA-SIZE 70016 :CHUNK-DATA
  #(-3.0517578e-5 -3.0517578e-5 -3.0517578e-5 -6.1035156e-5 -3.0517578e-5
    -6.1035156e-5 -6.1035156e-5 -9.1552734e-5 -6.1035156e-5 -9.1552734e-5
    -6.1035156e-5 -9.1552734e-5 -6.1035156e-5 -9.1552734e-5 -6.1035156e-5
    -6.1035156e-5 -6.1035156e-5 -6.1035156e-5 -3.0517578e-5 -3.0517578e-5 ...

さて、マニュアルを読むと、Limitationのところに「WAVを読み込むことはできるけど、書き出しは未対応です」なんて書いてありました。Pure Common Lispっぽいので可搬性が高くて良いのですが、FLACOGGを使いたいときもあるのでどうしたものか……。

bodge-sndfileを試す

LibSndFileは音ファイルを扱うための定番のCライブラリで、WAVだけでなく様々なファイルタイプに対応しています。それのCommon Lispラッパーがbodge-sndfileです。(他にもcl-libsndfileというのもあるようです)

(ql:quickload '(sndfile-blob bodge-sndfile))

(setq snd
      (sf:with-open-sound-file (sf-file "EBass_E1.wav")
        (sf:read-short-samples-into-array sf-file)))
;;=> #(0 0 0 1 -1 1 0 -1 3 -3 5 -5 5 -3 3 0 0 1 2 -2 5 -3 3 0 1 2 1 0 1 2 0 3 0 1 3
;;     -1 3 1 1 2 2 1 2 0 4 0 3 1 1 3 1 2 2 1 4 -1 5 -1 5 0 3 1 3 1 3 1 4 0 5 -1 5 1
;;     2 4 0 4 2 1 5 -1 6 1 2 4 0 5 1 3 3 2 2 3 3 1 6 -2 7 -1 5 2 2 4 1 5 2 2 5 1 3
;;     4 1 5 1 4 3 2 5 1 5 2 3 4 1 7 -1 6 3 1 6 2 3 4 4 1 6 2 5 2 5 3 2 6 0 7 2 6 1 ...

こんなかんじで読み込むことができます。リストではなくsimple-vectorにデータだけが入ってくるみたいですね。16ビットのファイルなら-32768(2^{16-1})~+32767(2^{16-1}-1)の範囲のデータになっているみたいです。

;; find min and max
(loop for x across snd3 minimize x)     ;=> -21134
(loop for x across snd3 maximize x)     ;=>  23196

-1~+1の範囲への正規化はこんな感じでやってあげれば良さそう。

;; normalize according to the number of bits
(coerce
 (let ((mx (expt 2 (1- 16))))       ; change here for other frame size
   (loop for x across snd3
         collect (float (/ x mx))))
 'simple-vector)
;;=> #(0.0 0.0 0.0 3.0517578e-5 -3.0517578e-5 3.0517578e-5 0.0 -3.0517578e-5
;;     9.1552734e-5 -9.1552734e-5 1.5258789e-4 -1.5258789e-4 1.5258789e-4
;;     -9.1552734e-5 9.1552734e-5 0.0 0.0 3.0517578e-5 6.1035156e-5 -6.1035156e-5
;;     1.5258789e-4 -9.1552734e-5 9.1552734e-5 0.0 3.0517578e-5 6.1035156e-5 ...

ファイルの情報を得るには以下のようにすればいいのかな?(ファイルのフォーマット情報、チャンネル数、標本化周波数、サンプル数、です。)

(sf:with-open-sound-file (sf-file "EBass_E1.wav")
  (format t "         Format: ~A~%" (sf:sound-format sf-file))
  (format t "Num of Channels: ~A~%" (sf:sound-channels sf-file))
  (format t "    Sample Rate: ~A~%" (sf:sound-sample-rate sf-file))
  (format t "   Sound Frames: ~A~%" (sf:sound-frames sf-file)))
;;=>          Format: (WAV PCM-16 FILE)
     Num of Channels: 1
         Sample Rate: 48000
        Sound Frames: 1440000

bodge-sndfileはLibSndFileに依存していますが、quickloadの時点で必要なバイナリを一緒に含めてくれる仕組みになっているような雰囲気がします。Windows上のSBCLでは問題なくquickloadできましたが、M1 Macではquickloadの時点で「サポートされていません」と出力されました。今のところx86_64のみに対応しているのが原因のようです。

cl-wavとbodge-sndfileの2つを使ってみましたが、どちらも一長一短です。bodge-sndfileのほうが便利な機能が色々ありそうですが、M1 Macならcl-wav一択かも……。

WAVファイルの書き出しまでやっているPure Common Lispコードとしては以下のページを見つけました。WAVは複雑なファイルフォーマットではないので、これを参考にして自作するのもいいですね。

note.com