音ファイルを書き出すwavwrite関数(Lisp Advent Calendar 2022)

この記事はLisp Advent Calendar 2022の11日目です。Lispには入門と挫折を繰り返していましたが、今年4月にLispに再入門し、なんとか日常的なイロイロに使えるようにしているところです。

普段使いするためには自分の専門分野のプログラムを作るのがよいだろうということで、このところCommon Lispで音や音楽の分析・合成に使えるオレオレ環境を作るべく、他言語での実装を参考にしながら車輪の再発明を続けています。普段はRとJuliaを使って信号処理や統計解析をしていますが、それらでやっていることをCommon Lispでできたら素敵です。*1

これまでLispで音を扱うために作ったプログラムは、以下の記事に書きました。今日は音ファイルを書き出すプログラムを書きます。

はじめに

先日、Common Lispで音ファイルを扱うライブラリの紹介をしました(Common Lispで音ファイルを読み込む - 丸井綜研)。LibSndFileをラップしているので当たり前ですが、bodge-sndfileは優秀で、WAV以外のフォーマットの読み書きにも対応しています。ただ、Apple Siliconには対応しておらず、QuickLispからインストールができません(おそらくインストール時にmachine-typeがx86-64でない場合ははじかれるのだと思います)。もう一方のcl-wavは書き出しが実装されていません。

そこで、Common Lispに用意された関数だけでWAVファイルの書き出し部分を作ってみました。お手本として「【Common Lisp】 Lispでサイン波を鳴らしてみる|菅原淳」を参考にしました。このページで紹介されているWAVファイル書き出しプログラムは16 bitかつ1 chのときだけを想定しているので、こちらは8、16、24、32 bitのマルチチャンネルWAVを書き出せるようにしようと思います。

実装

WAVファイルは出力するbit数に応じて型が変わります。8 bitのときはunsigned int、16と24 bitはsigned int、32 bitは(signed intも扱えますが多くの場合は)float型を採用しています。

まず、ファイルへのバイナリ書き出しのための関数です。8 bitではwrite-byteをそのまま使えばいいようです。1ワードがそれより長い場合はエンディアンを考慮しないといけません。WAVファイルはMicrosoft/Intel陣営で作られたことからリトルエンディアンを採用しているので、下位のバイトから順に出力していきます(ここは完全に「【Common Lisp】 Lispでサイン波を鳴らしてみる|菅原淳」をコピーしています)。

(defun write-16le (val out-stream)
  "Write a <val> to <out-stream> in little endian."
  (write-byte (ldb (byte 8 0) val) out-stream)
  (write-byte (ldb (byte 8 8) val) out-stream))

(defun write-24le (val out-stream)  
  "Write a <val> to <out-stream> in little endian."
  (write-byte (ldb (byte 8  0) val) out-stream)
  (write-byte (ldb (byte 8  8) val) out-stream)
  (write-byte (ldb (byte 8 16) val) out-stream))

(defun write-32le (val out-stream)
  "Write a <val> to <out-stream> in little endian."
  (write-byte (ldb (byte 8  0) val) out-stream)
  (write-byte (ldb (byte 8  8) val) out-stream)
  (write-byte (ldb (byte 8 16) val) out-stream)
  (write-byte (ldb (byte 8 24) val) out-stream))

(defun write-chars (str out-stream)
  "Write a string <str> to <out-stream>."
  (loop for c across str
        do (write-byte (char-code c) out-stream)))

似たような関数がたくさんあるので、もしかしたら下みたいな関数を作ってまとめてしまっても良いかもしれません。ただ、この関数はサンプルの数だけ呼び出されるので、あまり複雑にしないほうがいいのかもしれません。

(defun write-le (nbits val out-stream)
  "Write a <val> to <out-stream> in little endian."
  (let ((nbytes (/ nbits 8)))
    (loop for b from 0 to (1- nbytes) do
      (write-byte (ldb (byte 8 (* 8 b)) val) out-stream))))

つぎに、1サンプルずつ書き出すための関数です。8 bitのときは振幅[-1, +1)を0~255の整数で表します。振幅ゼロが128になります。16 bitと24 bitのときは、それぞれ振幅[-1, +1)を-32768~+32767、-8388608~+8388607の整数で表します。振幅ゼロが0になるので分かりやすいです。32 bitのときは[-1, +1)の範囲のIEEE 754浮動小数点数とします。

(defun write-sample-8 (val out-stream)
  (write-byte (truncate (+ 128 (scale-float val 7))) out-stream))

(defun write-sample-16 (val out-stream)
  (write-16le (truncate (scale-float val 15)) out-stream))

(defun write-sample-24 (val out-stream)
  (write-24le (truncate (scale-float val 23)) out-stream))

(defun write-sample-32 (val out-stream)
  (write-32le (ieee-floats:encode-float32 val) out-stream))

上記の準備をしたうえで、WAVファイルの書き出し関数です。まずはヘッダの最初の部分です。sndという変数にはサンプル数×チャンネル数の配列で振幅値が入っているものとします。fsは標本化周波数です。たとえば標本化周波数が48000 Hzでステレオの音が1秒ある場合、sndは48000×2の配列になります。モノラルの場合は2次元配列ではなくベクトルにしますので、その場合分けのためにarray-rankを使ったりしています。

さて、WAVはRIFFフォーマットのファイルです。RIFFはチャンク構造を使うのですが、それぞれのチャンクは「チャンク名・データ長・データそのもの」という形式になります。チャンク名とデータ長はそれぞれ32 bit(4 Byte)となります。WAVファイル全体は「RIFF・ファイルサイズ-8・データそのもの」みたいな形になります(8というのはRIFFの4 Byteとデータ長の4 Byteです)。ここでの総データ長はフォーマットチャンクの長さとデータチャンクの長さを加算したものですが、ちょっと複雑な計算になっています。

(defun wavwrite (filepath snd fs &optional (nbits 16))
  (let* ((nsmpl (array-dimension snd 0)) ; number of samples
         (nch (if (= 2 (array-rank snd))
                  (array-dimension snd 1)
                  1)))                  ; number of channels
    (with-open-file (out-stream filepath
                     :direction :output
                     :if-exists :supersede
                     :element-type '(unsigned-byte 8))
      (write-chars "RIFF" out-stream)
      (write-32le (+ 4 24 (if (= nbits 32) 14 0)
                     8 (* nsmpl nch (/ nbits 8))
                     (if (oddp (* nsmpl nch (/ nbits 8))) 1 0)) out-stream)
      (write-chars "WAVE" out-stream)

RIFFファイルはフォーマットチャンクとデータチャンクから成っています。まずはフォーマットチャンクを書きだします。フォーマットにはPCMなのかどうか、チャンネル数、標本化周波数などの情報が書かれています。作っていてハマったのは、振幅値が整数のときと浮動小数点数のときとでフォーマットタグが違うというところでした。また、浮動小数点数の時はfactチャンクが追加されるので、その対応も必要になっています。

      ;; format chunk 
      (write-chars "fmt " out-stream)
      (write-32le (if (= nbits 32) 18 16) out-stream) ; 16 for integer, 18 for float
      (write-16le (if (= nbits 32) 3 1) out-stream) ; 1 for integer, 3 for float
      (write-16le nch out-stream)                ; number of channels
      (write-32le fs out-stream)                 ; sampling frequency
      (write-32le (* fs (/ nbits 8)) out-stream) ; bytes per second per channel
      (write-16le (* nch (/ nbits 8)) out-stream) ; bytes per sample
      (write-16le nbits out-stream)               ; bits per sample
      (if (= nbits 32) (write-16le 0 out-stream)) ; extension size
      ;; fact chunk (for non-PCM formats like 32 bit float)
      (if (= nbits 32)
          (progn (write-chars "fact" out-stream)
                 (write-32le 4 out-stream)                ;chunk size
                 (write-32le (* nch nsmpl) out-stream))) ; sample length

ここからが振幅値を書き出すデータチャンクです。モノラルのときとマルチチャンネルのときで場合分けをしています。モノラルのデータを48000×1という2次元配列にするよりも、48000要素のベクトルを使う方が自然な気がしたからです。WAVのファイルサイズは偶数バイトになっていないといけないので、最後にその一文を入れています。

      ;; data chunk
      (write-chars "data" out-stream)
      (write-32le (* nsmpl nch (/ nbits 8)) out-stream) ; size of data chunk
      (cond
        ;; monaural
        ((and (= nbits 8) (= nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (write-sample-8 (aref snd k) out-stream)))
        ((and (= nbits 16) (= nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (write-sample-16 (aref snd k) out-stream)))
        ((and (= nbits 24) (= nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (write-sample-24 (aref snd k) out-stream)))
        ((and (= nbits 32) (= nch 1))
         (loop for k from 0 to (1- nsmpl)
               do (write-sample-32 (aref snd k) out-stream)))
        ;; multichannel
        ((and (= nbits 8) (> nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (loop for n from 0 to (1- nch) do
             (write-sample-8 (aref snd k n) out-stream))))
        ((and (= nbits 16) (> nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (loop for n from 0 to (1- nch) do
             (write-sample-16 (aref snd k n) out-stream))))
        ((and (= nbits 24) (> nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (loop for n from 0 to (1- nch) do
             (write-sample-24 (aref snd k n) out-stream))))
        ((and (= nbits 32) (> nch 1))
         (loop for k from 0 to (1- nsmpl) do
           (loop for n from 0 to (1- nch) do
             (write-sample-32 (aref snd k n) out-stream))))
        ;; neither
        (t (error "Unsupported WAV format.")))
      ;; add trailing zero to make the file length even
      (if (oddp (* nsmpl nch (/ nbits 8)))
          (write-byte 0 out-stream)))))

TSP信号の生成 - 丸井綜研」で作ったOATSP信号をノーマライズしてWAVファイルに書き出してみました。うまくいっているようです。

(defun normalize-signal (snd &optional (dB -1.0))
  "Normalize signal to specified level (-1 dB by default)."
  (let ((atten (expt 10 (/ dB 20)))
        (res (make-array (array-dimensions snd)))
        (mm (loop for k from 0 to (1- (array-total-size snd))
                  maximize (row-major-aref snd k))))
    (loop for k from 0 to (1- (array-total-size snd))
          do (setf (row-major-aref res k) (* (/ (row-major-aref snd k) mm) atten)))
    res))

(wavwrite "oatsp.wav" (normalize-signal (make-oatsp (expt 2 18) 80000)) 48000 24)

反省点

ここからは、いろいろ反省点というか改善したいところです。改善できたら別記事にしたいです。

出力するWAVファイルのビット幅ごとに関数を作ったのですが、それを一本化して `(write-sample-,nbits) のように書きたかったです。ただ、そのままやると (write-sample- 24) のようにスペースが空いてしまいました。これをするのに (eval (read-from-string (format ... ))) みたいな方法があるかと思って

(defmacro eval-string (control-string &rest args)
  (let ((*read-eval* nil))
    (read-from-string (eval `(format nil ,control-string ,@args)))))

を作ってみましたが、

(eval-string "(write-sample-~D (aref snd k) out-stream)" nbits)

のnbitsは実行時まで決まらないので(コンパイル時には値がわからないので)、使えないよと言われてしまいました。nbitsというコードはそのままにしておいてくれればいいんだけど。defmacroをdefunにしても動かないし、単に関数の呼び出し回数が増えるだけ。マクロの使い方や使いどころが全く理解できてないので、もっとLispに慣れないと、です。

また、(row-major-aref)に似た(column-major-ref)があれば、チャンネル数が1かそれ以上かの場合分けをせずにループを回せるのに、とも思いました。モノラル信号を2次元配列に変換して、その転置行列を使えば解決できそうです。(2022/12/12に行列の転置 - 丸井綜研にベクトルの転置を追記しました)

*1:以前はMatlab使ってましたが、当時はJITが未実装だったので、どうしても実行速度が必要なときにC言語で書き直していました。Juliaだとあるていどどんなコードでも速く動いてくれるので、このところMatlabは触っていません。