音に触る

Last Change:23-May-2016.
Author:qh73xe
contents:WAV ファイルの読み込み、書き出し

本章では、波である音を、PC 上で扱っていくための手順を概説します。

まずは、論理的な背景として音を PC で扱う上でどのような操作をする必要が あるのかを説明し、 その上で、実際問題今後相手にしていく音声を具体的に C 言語で読み込むには どうすればよいかについて説明します。

論理編

A-D変換とD-A変換

これはよく知られた話であると思いますが、音は波形です。 で、波形はアナログ、つまりは切れ目なく、連続的に変化するものです。

一方で、コンピュータは基本的にはデジタル、つまり 0 と 1 の離散的な世界で生きています。 そのため、音をコンピュータで扱うためには、まずはアナログな波形をデジタルなデータに変換する必要があります。

このそもそも論の変換作業を A-D 変換 といいます。 A-D 変換は 標本化周期 という時間間隔でアナログ信号を変換する 標本化 と、 標本化したアナログ信号を数値化する 量子化 という二つの処理によって、アナログ信号をデジタル信号に変換します。

注釈

標本化と量子化

後で詳しい説明を行いますが、 一読しただけでは標本化と量子化の違いは分かりづらいかもしれません。

まず波形は、時間と振幅の二つの軸が存在します。 これは実世界においては両方ともに連続な値です。

そのため、PC上ではこの二つの軸をそれぞれ離散的に変換する必要があります。 その上で時間に対する処理を “標本化” と呼び、振幅に対する処理を “量子化” と呼ぶと思えばよいです。

変換、変換とさも複雑なことを言っているように聞こえますが、 A-D 変換の基本的な発想は何のことはなく、ようは、一定のタイミングで各値を計測するだけです。 この一定の区切りが細かいほど、波の形状を正確に示すことができるのは、とても当たり前なことだと思います。 極端な話、この区切りが無限にあれば、それはアナログと同じわけですから。

しかし、それができたらわざわざ A-D 変換なんかしなくてよいわけで、 ここに “ある信号の再現精度と、データ量とのトレードオフ” が発生します。

このトレードオフの最適解がどこなのかというのが、次の議論です。

標本化

まずは標本化の話からしていきましょう。 標本化の細かさは、1秒当たりに何回標本化を行うのか、という話です。 これのことを 標本化周期 といいます。 ここまでの説明を数式にすると以下のようになります。

\[t_s = \frac{1}{f_s}\]

\(t_s\) が標本化周期で、 \(f_s\) が何回標本化を行うのか (標本化周波数) です。 なお、標本化周波数の単位は Hz (ヘルツ) といいます。

では、この標本化周波数の最適解はいくつなのか。 これを設定する際の指標にされているのが シャノンの標本化定理 という法則です。 この定理の証明を行おうとするのは結構面倒なのでここでは内容だけ説明すると、 以下の通りです。

シャノンの標本化定理::
標本化周波数の \(\frac{1}{2}\) 以下の周波数成分についてはアナログ信号もデジタル信号も数学的には等価である

人間の聴覚は 20Hz ~ 20 k Hz までの範囲の周波数成分を音として知覚しているというのが通説なので、 これをシャノンの標本化定理に当てはめると、 \(\frac{1}{2}x = 20\) kHz である ため、 標本化周波数は 40 kHz 以上に設定しておけば問題がなさそうであると言えます。

この原理に基づいて CD などでは一般に 44.1 kHz の標本化周波数を使用しています。

ただし、アプリケーションの実装という意味では多少データの品質が悪くなっても問題ない場合もあります。 例えば電話などはその代表例で標本化周波数は 8 k Hz しかありません。 この程度でも人間の耳には何を言っているのか程度はわかります。

注釈

コラム1: 音データの標本化周波数

先に CD の標本化周波数が 44.1 k Hz であると紹介しましたが、 中途半端な 4.1 k Hz は何処から来たのか? と疑問に思う方もいるかもしれません。 結論から言えば、これは論理的な決定というよりも歴史的、政治的決定です。

もともと、この規格はビデオテープに音データを記録する際に決定されました。 ビデオテープは一秒間に30回の速度で 525 本の走査線からなる映像データを記録する 仕組みになっています。この内 490 本の走査線を選び、 3 サンプルの音データを記録すると \(490 \times 3 \times 30 = 44100\) となります。 CD の出現時期は この装置との互換性を考慮する必要があったため、このような中途半端な値になっています。

標本化をする際にもう一つ気をつける必要のある現象にエイリアス歪みというものがあります。

エイリアス歪み::
アナログ信号に標本化周波数の \(\frac{1}{2}\) よりも大きい周波数成分が含まれていると、 見た目上、 標本化周波数の \({1}{2}\) 以下の周波数成分であるかのようにデジタル信号に 変換されてしまう現象

このため、実際に 標本化を行う際には、事前に標本化周波数の \(\frac{1}{2}\) よりも大きい周波数成分を取り除いてしまいます。 この処理は低域通過フィルタ(LPF) を使うことで実現します。

量子化

続いて量子化に関する話をしていきます。 量子化の細かさは、元の振幅を2進法を使い、表現する際に必要になる桁数で表します。 これを 量子化精度 といい 単位は bit です。 bit 数が大きくなれば、その分表現できる元の振幅の精度も大きくなります。

この適切な設定をどうするのかという問題がありますが、 例えば CD の場合 16 bit が一般的です。

量子化精度に関する尺度としては、 ダイナミックレンジ と呼ばれるものがあります。

ダイナミックレンジ::

媒体に記録することができる最も大きい音と最も小さい音の比として定義された尺度。

\[dB \simeq 20 \log_{10} {\frac{2^b}{1}}\]
  • b: bit 数
  • 単位は dB: デシベル

例えば CD の場合、ダイナミックレンジは \(20 \log_{10}{\frac{2^{16}}{1}} \simeq 96 dB\) です. 音楽 CD の量子化精度は、規格が提案された 1980 年代当時の技術水準を背景に設定されています。

そのため、この設定は一般に充分な精度ではありますが、より高精度の音データの記録も、今の記述なら可能です。 ハイレゾ音源などは、ここら辺の規格の話になります。

D-A 変換

ここまででは、 A-D 変換、つまり、音声をどのようにコンピュータに取り込むのかという話をしてきました。 しかし、一般にアプリケーションを作成する際には、取り込んだだけではなく、再生する必要があります。

この際,デジタルなデータからアナログな波形に戻す処理、 D-A 変換が必要になります。 D-A 変換は A-D 変換の逆の処理を行いますが、この際に、標本化周波数の \(\frac{1}{2}\) よりも大きい周波数成分が発生してしまいます。 そのため、 D-A 変換を行った後には再度 LPF の処理を行う必要があります。

WAVEファイル

PC 上で A-D 変換を行ったデータを保存する際には一般に wav もしくは wave ファイルという形式で保存されることが多いです。 そうです. 今までの話は結局 wav ファイルに保存されているデータはどのように作成されているのかを概説していたようなものなのです。 この節では、この wav ファイルがどのような形式のデータなのかを説明していきます。

以下に、 wave ファイルの構造を示します。 ここに示すように、 wave ファイルは チャンク と呼ばれるブロック構造によって音データを記録しています。 この表では記述して居ませんが、 wav ファイル全体は RIFF チャンク というブロックになっています。

wave ファイルの構造
パラメータ byte 内容 チャンク
chunkID 4 RIFF なし
chunkSize 4 size+36 なし
chunkType 4 WAVE なし
chunkID 4 fmt_ fmt チャンク
chunkSize 4 16 fmt チャンク
waveFormatType 2 プロパティ fmt チャンク
channel 2 プロパティ fmt チャンク
samplesPerSec 4 プロパティ fmt チャンク
bytesPerSec 4 プロパティ fmt チャンク
blockSize 2 プロパティ fmt チャンク
bitsPerSec 2 プロパティ fmt チャンク
chunkID 4 data data チャンク
chunkSize 4 size data チャンク
data size 音データ data チャンク

一般的な WAVE ファイルはこの RIFF チャンクの中に fmt チャンクdata チャンク という二つのチャンクを格納しています。

fmt チャンク::
標本化周波数や量子化周波数などの音データに関するプロパティ情報
data チャンク::
音データそのもの

それぞれのチャンクにある chunkSize は ID と Size を除いたチャンクサイズを byte 単位で表現したものです。

実装編

ようやく、プログラミング系の話です。 ここまでの話は、端的に言えば 音データはどのように作成され、具体的にはどのような形式で保存されているのかという話でした。

つまり、データの話しかしていません。 このため、あまり面白くはない例ですが、 ここでは C 言語で、 wave ファイルを読み出したり、 書き出したりすることだけを考えます。

警告

ここでの想定に関して

上の説明では、 A-D 変換について重きをおいて説明をしています。 それを考えると、本来は録音部分に関する処理が必要です。

しかし、一般には録音機自体を実装することはあまりなく、 それこそ、ある音はすでに WAVE ファイルになっていて、 これを解析することが多いかと思います。

そのため、以下の例では、 ある wave ファイルを読み込んで何らかの処理を行う際の話を前提に プログラムを作成します。

まず、何が必要かと考えると、 以下のような手順かと思います。

  1. wave ファイルから fmt チャンク情報を取得する。
  2. この情報をもとに data チャンクからデータを取得する。
  3. 何らかの処理を行う

以後の章では 3 の何らかの処理について説明をしていくのですが、 ここでは、単純に、何も変更を加えずにコピーのみをして保存することを考えます。

fmt チャンク情報の取得

まずは wave ファイルの情報に正しく意味を持たせるような構造体を作成していきます。 wave ファイルの fmt 情報の内、とりあえず必須なものは、以下の要素です。

  • サンプリング周波数
  • 量子化精度
  • 長さ(時間)

また、実際の離散的波形データもこのクラスの中で保存することにしましょう。

こう考えると、例えば以下のような構造体を作成するのがよいのではないでしょうか。

MONO_PCM
1
2
3
4
5
6
7
typedef struct
{
  int fs;
  int bits;
  int length;
  double *s;
} MONO_PCM;

読み込み関数の作成

続いて、ファイルの読み込み関数を作成していきます。

mono_wave_read
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 void mono_wave_read(MONO_PCM *pcm, char *file_name)
 {
   // 変数宣言
   FILE *fp;
   int n;
   char riff_chunk_ID[4];
   long riff_chunk_size;
   char riff_form_type[4];
   char fmt_chunk_ID[4];
   long fmt_chunk_size;
   short fmt_wave_format_type;
   short fmt_channel;
   long fmt_samples_per_sec;
   long fmt_bytes_per_sec;
   short fmt_block_size;
   short fmt_bits_per_sample;
   char data_chunk_ID[4];
   long data_chunk_size;
   short data;

   // ファイルの読み込み
   fp = fopen(file_name, "rb");
   fread(riff_chunk_ID, 1, 4, fp);
   fread(&riff_chunk_size, 4, 1, fp);
   fread(riff_form_type, 1, 4, fp);
   fread(fmt_chunk_ID, 1, 4, fp);
   fread(&fmt_chunk_size, 4, 1, fp);
   fread(&fmt_wave_format_type, 2, 1, fp);
   fread(&fmt_channel, 2, 1, fp);
   fread(&fmt_samples_per_sec, 4, 1, fp);
   fread(&fmt_bytes_per_sec, 4, 1, fp);
   fread(&fmt_block_size, 2, 1, fp);
   fread(&fmt_bits_per_sample, 2, 1, fp);
   fread(data_chunk_ID, 1, 4, fp);
   fread(&data_chunk_size, 4, 1, fp);

   // 構造体にデータを挿入
   pcm->fs = fmt_samples_per_sec;
   pcm->bits = fmt_bits_per_sample;
   pcm->length = data_chunk_size / 2;
   pcm->s = calloc(pcm->length, sizeof(double));

   for (n = 0; n < pcm->length; n++)
   {
     fread(&data, 2, 1, fp);
     pcm->s[n] = (double)data / 32768.0;
   }

   // ファイルを閉じる
   fclose(fp);
 }

長いですね。 長いですが、すごく率直に記述しています。

とりあえず、型宣言の部分は良いかと思います。 上で wav ファイルの説明を行っているので、 その形式に合わせて宣言をしています。

  • data に関しては最終的には 2 進法で記述されるはずなので short 型を利用します。
  • 一方 long 型は 4 バイト符号化です。 上の wave ファイルの規格表を参照してください。

ファイルの読み込み部分に関しては、 fread 関数さえ知っていればよいでしょう。

size_t fread(void *buf, size_t size, size_t n, FILE *fp); ::
  • buf: 格納先のバッファ
  • size: 読み込むデータのバイト数
  • n: 読み込みデータ数
  • fp: ファイルポインタ

pcm->s[n] = (double)data / 32768.0; の記述は不思議に思うかも知れません。 まず、このソースは 16 bit で量子化された wave ファイルを前提にしています。 16 bit の二進法で表現できる範囲は、 \(2^{16} = 65536\) , つまり、10進法では、32767 ~ -32768 までの値しか表現できません。 ここで、 32767 + 1 のような計算をさせると 10 進法では -32768 になり負の数に逆転してしまいます。 こうした現象を オーバーフロー というのですが、これを防ぐために、 -1 ~ 1 の範囲に正規化を行っています。

データの書き出し

続いてはデータの書き出し部分を見ていきます。 これは以下のようにすればよいでしょう。

mono_wave_read
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
 void mono_wave_write(MONO_PCM *pcm, char *file_name)
 {
   // 型宣言部
   FILE *fp;
   int n;
   char riff_chunk_ID[4];
   long riff_chunk_size;
   char riff_form_type[4];
   char fmt_chunk_ID[4];
   long fmt_chunk_size;
   short fmt_wave_format_type;
   short fmt_channel;
   long fmt_samples_per_sec;
   long fmt_bytes_per_sec;
   short fmt_block_size;
   short fmt_bits_per_sample;
   char data_chunk_ID[4];
   long data_chunk_size;
   short data;
   double s;

   // チャンク情報の挿入
   riff_chunk_ID[0] = 'R';
   riff_chunk_ID[1] = 'I';
   riff_chunk_ID[2] = 'F';
   riff_chunk_ID[3] = 'F';
   riff_chunk_size = 36 + pcm->length * 2;
   riff_form_type[0] = 'W';
   riff_form_type[1] = 'A';
   riff_form_type[2] = 'V';
   riff_form_type[3] = 'E';

   fmt_chunk_ID[0] = 'f';
   fmt_chunk_ID[1] = 'm';
   fmt_chunk_ID[2] = 't';
   fmt_chunk_ID[3] = ' ';
   fmt_chunk_size = 16;
   fmt_wave_format_type = 1;
   fmt_channel = 1;
   fmt_samples_per_sec = pcm->fs;
   fmt_bytes_per_sec = pcm->fs * pcm->bits / 8;
   fmt_block_size = pcm->bits / 8;
   fmt_bits_per_sample = pcm->bits;

   data_chunk_ID[0] = 'd';
   data_chunk_ID[1] = 'a';
   data_chunk_ID[2] = 't';
   data_chunk_ID[3] = 'a';
   data_chunk_size = pcm->length * 2;

   // ファイルオープン
   fp = fopen(file_name, "wb");

   // ファイルへの書き出し
   fwrite(riff_chunk_ID, 1, 4, fp);
   fwrite(&riff_chunk_size, 4, 1, fp);
   fwrite(riff_form_type, 1, 4, fp);
   fwrite(fmt_chunk_ID, 1, 4, fp);
   fwrite(&fmt_chunk_size, 4, 1, fp);
   fwrite(&fmt_wave_format_type, 2, 1, fp);
   fwrite(&fmt_channel, 2, 1, fp);
   fwrite(&fmt_samples_per_sec, 4, 1, fp);
   fwrite(&fmt_bytes_per_sec, 4, 1, fp);
   fwrite(&fmt_block_size, 2, 1, fp);
   fwrite(&fmt_bits_per_sample, 2, 1, fp);
   fwrite(data_chunk_ID, 1, 4, fp);
   fwrite(&data_chunk_size, 4, 1, fp);

   // データチャンクの挿入
   for (n = 0; n < pcm->length; n++){
     s = (pcm->s[n] + 1.0) / 2.0 * 65536.0;

     if (s > 65535.0){
       s = 65535.0;
     } else if (s < 0.0) {
       s = 0.0;
     }
     data = (short)(s + 0.5) - 32768;
     fwrite(&data, 2, 1, fp);
   }
   fclose(fp);
 }

恐ろしく単調なコードですが、 こんな感じでよいでしょう。

すこし説明が必要なところは data チャンクの挿入部分でしょうか. この部分の if 文では先程説明した オーバーフローが生じてしまう場合に、 上限、下限を設定し、音データの振幅を打ち切ることにしています。

  • ちなみにこの処理のことをクリッピングといいます。

すべての処理を記述

今まで作成したコード片に関しては今後の処理でも必要になるので、 ここでは h ファイルとして保存することにします。

wave.h
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
typedef struct
{
  int fs;
  int bits;
  int length;
  double *s;
} MONO_PCM;

void mono_wave_read(MONO_PCM *pcm, char *file_name)
{
  FILE *fp;
  int n;
  char riff_chunk_ID[4];
  long riff_chunk_size;
  char riff_form_type[4];
  char fmt_chunk_ID[4];
  long fmt_chunk_size;
  short fmt_wave_format_type;
  short fmt_channel;
  long fmt_samples_per_sec;
  long fmt_bytes_per_sec;
  short fmt_block_size;
  short fmt_bits_per_sample;
  char data_chunk_ID[4];
  long data_chunk_size;
  short data;

  fp = fopen(file_name, "rb");

  fread(riff_chunk_ID, 1, 4, fp);
  fread(&riff_chunk_size, 4, 1, fp);
  fread(riff_form_type, 1, 4, fp);
  fread(fmt_chunk_ID, 1, 4, fp);
  fread(&fmt_chunk_size, 4, 1, fp);
  fread(&fmt_wave_format_type, 2, 1, fp);
  fread(&fmt_channel, 2, 1, fp);
  fread(&fmt_samples_per_sec, 4, 1, fp);
  fread(&fmt_bytes_per_sec, 4, 1, fp);
  fread(&fmt_block_size, 2, 1, fp);
  fread(&fmt_bits_per_sample, 2, 1, fp);
  fread(data_chunk_ID, 1, 4, fp);
  fread(&data_chunk_size, 4, 1, fp);

  pcm->fs = fmt_samples_per_sec;
  pcm->bits = fmt_bits_per_sample;
  pcm->length = data_chunk_size / 2;
  pcm->s = calloc(pcm->length, sizeof(double));

  for (n = 0; n < pcm->length; n++)
  {
    fread(&data, 2, 1, fp);
    pcm->s[n] = (double)data / 32768.0;
  }
  fclose(fp);
}

void mono_wave_write(MONO_PCM *pcm, char *file_name)
{
  FILE *fp;
  int n;
  char riff_chunk_ID[4];
  long riff_chunk_size;
  char riff_form_type[4];
  char fmt_chunk_ID[4];
  long fmt_chunk_size;
  short fmt_wave_format_type;
  short fmt_channel;
  long fmt_samples_per_sec;
  long fmt_bytes_per_sec;
  short fmt_block_size;
  short fmt_bits_per_sample;
  char data_chunk_ID[4];
  long data_chunk_size;
  short data;
  double s;

  riff_chunk_ID[0] = 'R';
  riff_chunk_ID[1] = 'I';
  riff_chunk_ID[2] = 'F';
  riff_chunk_ID[3] = 'F';
  riff_chunk_size = 36 + pcm->length * 2;
  riff_form_type[0] = 'W';
  riff_form_type[1] = 'A';
  riff_form_type[2] = 'V';
  riff_form_type[3] = 'E';

  fmt_chunk_ID[0] = 'f';
  fmt_chunk_ID[1] = 'm';
  fmt_chunk_ID[2] = 't';
  fmt_chunk_ID[3] = ' ';
  fmt_chunk_size = 16;
  fmt_wave_format_type = 1;
  fmt_channel = 1;
  fmt_samples_per_sec = pcm->fs;
  fmt_bytes_per_sec = pcm->fs * pcm->bits / 8;
  fmt_block_size = pcm->bits / 8;
  fmt_bits_per_sample = pcm->bits;

  data_chunk_ID[0] = 'd';
  data_chunk_ID[1] = 'a';
  data_chunk_ID[2] = 't';
  data_chunk_ID[3] = 'a';
  data_chunk_size = pcm->length * 2;

  fp = fopen(file_name, "wb");

  fwrite(riff_chunk_ID, 1, 4, fp);
  fwrite(&riff_chunk_size, 4, 1, fp);
  fwrite(riff_form_type, 1, 4, fp);
  fwrite(fmt_chunk_ID, 1, 4, fp);
  fwrite(&fmt_chunk_size, 4, 1, fp);
  fwrite(&fmt_wave_format_type, 2, 1, fp);
  fwrite(&fmt_channel, 2, 1, fp);
  fwrite(&fmt_samples_per_sec, 4, 1, fp);
  fwrite(&fmt_bytes_per_sec, 4, 1, fp);
  fwrite(&fmt_block_size, 2, 1, fp);
  fwrite(&fmt_bits_per_sample, 2, 1, fp);
  fwrite(data_chunk_ID, 1, 4, fp);
  fwrite(&data_chunk_size, 4, 1, fp);

  for (n = 0; n < pcm->length; n++)
  {
    s = (pcm->s[n] + 1.0) / 2.0 * 65536.0;

    if (s > 65535.0)
    {
      s = 65535.0;
    }
    else if (s < 0.0)
    {
      s = 0.0;
    }
    data = (short)(s + 0.5) - 32768;
    fwrite(&data, 2, 1, fp);
  }
  fclose(fp);
}

そのうえで、実行部分を作成していきます。 以下の音源を読み込み、単純にコピーし、書き込みを行う感じに記述します。

これは説明をする必要のある部分は特にないかとおもいます。 このコードをコンパイルすると、 a.wave を読み込み b.wave という名前で、 同じデータをコピーします。 以下に a.wav を配置しておくので、挙動を確かめてみてください。

a.wav