シェルスクリプトとAWKでWAV生成


概要

シェルスクリプトとAWKでWAV生成してみたい。

シェルスクリプトでバイナリを扱う

最終的にWAVを生成するので、バイナリを扱える必要がある。

シェルスクリプトでバイナリを扱うとコマンド置換などいろんな箇所で問題が出る。

なので、最終段階までは、バイナリをテキストデータで扱うことにする。

最も汎用性が高そうな方式として、1バイトを16進数2文字 00ff で表す方式で扱う。

これを本記事ではHexdump形式と呼ぶ。

文字列からHexdump形式への変換は、od コマンドを使って、

stringToHexdump() {
  od -An -tx1 | tr -d ' \n'
}

とできる。

逆に、Hexdump形式からバイナリへの変換は、

hexdumpToBinary() {
  fold -w2 | sed 's/^/0x/' | xargs printf '\\\\0%o\n' | xargs printf '%b'
}

とできる。

まず、文字列を2文字ずつ改行して、先頭に 0x を付ける。

これを、printf での %o により、8進数に変換しつつ先頭に \0 を付加している。

printfxargs それぞれでエスケープが必要になるため、\ は4つになっている。

printf は、

https://pubs.opengroup.org/onlinepubs/000095399/utilities/printf.html

に書かれているように、%b を使うことで引数に8進数を指定し1バイトのバイナリを出力できる。
このときの引数に与える8進数は、\0111 のような表記になる。

これによって、Hexdump形式がバイナリに変換できるようになった。

なお、汎用性は下がるが、xxd を使えばそれぞれ、xxd -pxxd -p -r とすることもできる。

WAVファイルのヘッダをHexdump形式で生成

WAVファイルのヘッダは、16bit値、32bit値をリトルエンディアンで書き込める必要がある。

そのための関数を準備する。

uint8Hexdump() {
  printf '%02x' $1
}

uint16LeHexdump() {
  uint8Hexdump $(($1 % 0x100))
  uint8Hexdump $((($1 / 0x100) % 0x100))
}

uint32LeHexdump() {
  uint16LeHexdump $(($1 % 0x10000))
  uint16LeHexdump $((($1 / 0x10000) % 0x10000))
}

出力は、ここまでの解説の通り、Hexdump形式になっている。

ここまでの関数を使って、WAVファイルのヘッダは、

getHeaderHexdump() (
  sampleRate=$1
  samplesLen=$2
  bytesPerSample=$3
  printf 'RIFF' | stringToHexdump
  uint32LeHexdump $((32 + samplesLen * bytesPerSample))
  printf 'WAVE' | stringToHexdump
  printf 'fmt ' | stringToHexdump
  uint32LeHexdump 16
  uint16LeHexdump 1
  uint16LeHexdump 1
  uint32LeHexdump $sampleRate
  uint32LeHexdump $((sampleRate * bytesPerSample))
  uint16LeHexdump $bytesPerSample
  uint16LeHexdump $((8 * bytesPerSample))
  printf 'data' | stringToHexdump
  uint32LeHexdump $((samplesLen * bytesPerSample))
)

と取得できるようになった。これもHexdump形式だ。

オーディオデータの生成

オーディオデータはHexdump形式で生成してもよいのだが、もう少し汎用性を上げるために、
1行ごとに-1.0~1.0が書き込まれた形式を中間で用意することにする。

これを本記事ではAudioData形式と呼ぶ。

16bitモノラルとして、AudioData形式からHexdump形式への変換は、
AWKを使って、

audioDataToHexdump() {
  awk '
    {
      i = int(0x7fff * $1);
      if (i < 0) i += 0xffff;
      printf("%02x%02x", i % 0x100, (i / 0x100) % 0x100);
    }
  '
}

と書ける。-1.0~1.0の値を、リトルエンディアン16bit値にして Hexdump 形式で書き込んでいる。

処理の流れ

これまでの流れを組み合わせて、

sampleRate=22050
durationSeconds=5
samplesLen=$((durationSeconds * sampleRate))
bytesPerSample=2

{
  getHeaderHexdump $sampleRate $samplesLen $bytesPerSample
  getAudioData $sampleRate $samplesLen | audioDataToHexdump
} | hexdumpToBinary > output.wav

とすれば、WAVファイルを出力できるようになった。

あとは、AudioData形式を出力する getAudioData を自由に実装すればよい。

波形の生成

https://qiita.com/htsnul/items/3e001e70e7144dda07a6 の前半の1秒ずつドレミを鳴らす処理を、
AWKを使ったシェルスクリプトに移植しよう。

getAudioData() {
  sampleRate=$1
  samplesLen=$2
  seq 0 $((samplesLen - 1)) | awk -v sampleRate=$sampleRate '
    BEGIN {
      PI = atan2(0, -1);
    }

    function angularVelFromNoteNumber(nn) {
      return 2 * PI * 440 * 2 ** ((nn - 69) / 12);
    }

    function toneSimple(t, duration, nn,
      angVel \
    ) {
      if (t < 0 || duration < t) return 0;
      angVel = angularVelFromNoteNumber(nn); 
      return 0.05 * sin( \
        angVel * t + \
        4 * sin(1 * angVel * t) \
      );
    }

    function sample(t,
      d \
    ) {
      d = 0;
      d += toneSimple(t - 0, 1, 72);
      d += toneSimple(t - 1, 1, 74);
      d += toneSimple(t - 2, 1, 76);
      return d;
    }

    {
      t = $1 / sampleRate;
      print sample(t);
    }
  '
}

これでドレミと鳴るWAVファイルが生成できた。

全コード

#!/bin/bash
stringToHexdump() {
  od -An -tx1 | tr -d ' \n'
}

hexdumpToBinary() {
  fold -w2 | sed 's/^/0x/' | xargs printf '\\\\0%o\n' | xargs printf '%b'
}

uint8Hexdump() {
  printf '%02x' $1
}

uint16LeHexdump() {
  uint8Hexdump $(($1 % 0x100))
  uint8Hexdump $((($1 / 0x100) % 0x100))
}

uint32LeHexdump() {
  uint16LeHexdump $(($1 % 0x10000))
  uint16LeHexdump $((($1 / 0x10000) % 0x10000))
}

getHeaderHexdump() (
  sampleRate=$1
  samplesLen=$2
  bytesPerSample=$3
  printf 'RIFF' | stringToHexdump
  uint32LeHexdump $((32 + samplesLen * bytesPerSample))
  printf 'WAVE' | stringToHexdump
  printf 'fmt ' | stringToHexdump
  uint32LeHexdump 16
  uint16LeHexdump 1
  uint16LeHexdump 1
  uint32LeHexdump $sampleRate
  uint32LeHexdump $((sampleRate * bytesPerSample))
  uint16LeHexdump $bytesPerSample
  uint16LeHexdump $((8 * bytesPerSample))
  printf 'data' | stringToHexdump
  uint32LeHexdump $((samplesLen * bytesPerSample))
)

audioDataToHexdump() {
  awk '
    {
      i = int(0x7fff * $1);
      if (i < 0) i += 0xffff;
      printf("%02x%02x", i % 0x100, (i / 0x100) % 0x100);
    }
  '
}

getAudioData() {
  sampleRate=$1
  samplesLen=$2
  seq 0 $((samplesLen - 1)) | awk -v sampleRate=$sampleRate '
    BEGIN {
      PI = atan2(0, -1);
    }

    function angularVelFromNoteNumber(nn) {
      return 2 * PI * 440 * 2 ** ((nn - 69) / 12);
    }

    function toneSimple(t, duration, nn,
      angVel \
    ) {
      if (t < 0 || duration < t) return 0;
      angVel = angularVelFromNoteNumber(nn); 
      return 0.05 * sin( \
        angVel * t + \
        4 * sin(1 * angVel * t) \
      );
    }

    function sample(t,
      d \
    ) {
      d = 0;
      d += toneSimple(t - 0, 1, 72);
      d += toneSimple(t - 1, 1, 74);
      d += toneSimple(t - 2, 1, 76);
      return d;
    }

    {
      t = $1 / sampleRate;
      print sample(t);
    }
  '
}

sampleRate=22050
durationSeconds=5
samplesLen=$((durationSeconds * sampleRate))
bytesPerSample=2

{
  getHeaderHexdump $sampleRate $samplesLen $bytesPerSample
  getAudioData $sampleRate $samplesLen | audioDataToHexdump
} | hexdumpToBinary > output.wav

パイプで加工

データ形式が素直なので、パイプでAudioData形式を加工することができる。

例えば、

-  getAudioData $sampleRate $samplesLen | audioDataToHexdump
+  getAudioData $sampleRate $samplesLen | tac | audioDataToHexdump

のように tac コマンドを挟むことで出力を逆再生に変換できる。

  getAudioData $sampleRate $samplesLen | awk '{ print $1 / 4; }' | audioDataToHexdump

とすれば、音量を1/4に下げることができる。

  getAudioData $sampleRate $samplesLen \
    | awk '{ d = $1 + b[(NR + 1) % 5000] / 2; b[NR % 5000] = d; print d; }' \
    | audioDataToHexdump

とすればディレイ効果を掛けることができる。

コマンドやAWKをオーディオ効果を適用するノードのように見立てることができる。

まとめ

実用性はともかく、シェルスクリプトとAWKでWAV生成できるようになった。