ブラウザ上で.wavファイルを解析する


この記事は、[Treasure Advent Calendar 2018](https://qiita.com/advent-calendar/2018/treasure)の20日目の記事です。(大遅刻しました...)

最近React×Redux×WebAudioAPIを使ってアプリケーションを作っていて、ブラウザ上で音声の解析をした時のメモを書きます。

## 今回やりたいこと
ブラウザにアップロードした.wavファイルを解析し、サンプリングレートなどの情報と波形データを読み込む。


## 手順
1. FileReaderを使ってArrayBufferとしてファイルを読み込む
2. (audioタグにurlを入れ、再生できるようにする)
3. .wavのデータを解析
4. 結果をいい感じに表示する

* 使用したサンプル音声(ダウンロードできます)
  * https://soundcloud.com/rkonatsu/kaeru-44100-16-1


以下の2つのファイルを拡張しながら説明していきます。

```index.html 
<!DOCTYPE html>
<html>
  <head>
    <script src="./index.js"></script>
  </head>
  <body>
    <input id="myFile" type="file" />
    <audio id="audio" controls></audio>
    <div id="result"></div>
  </body>
</html>
```

``` index.js
window.onload = () => {
  const element = document.getElementById('myFile')
  const audioElement = document.getElementById('audio')
  const resultElement = document.getElementById('result')
}
```

### 1. ファイル読み込み
https://lab.syncer.jp/Web/API_Interface/Reference/IDL/FileReader/readAsArrayBuffer/ を参考にinputタグからファイルを読み込みます。

```index.js
window.onload = () => {
  const element = document.getElementById('myFile')
  const audioElement = document.getElementById('audio')
  const resultElement = document.getElementById('result')

  element.onchange = () => {
    // ファイルが選択されたか
    if (!element.value) return

    // FileReaderクラスに対応しているか
    if (!window.FileReader) return

    // 0番目を読み込む
    const file = element.files[0]
    if (!file) return

    const fileReader = new FileReader()

    fileReader.onload = () => {
      // fileReader.resultにデータが入る
      console.log(fileReader.result)
    }

    fileReader.readAsArrayBuffer(file)
  }
}
```
Consoleを見ると、データの読み込み結果が表示されます。

```
ArrayBuffer(355290) {}
[[Int8Array]]: Int8Array(355290) [82, 73, 70, …]
[[Int16Array]]: Int16Array(177645) [18770, 17990, 27602, …]
[[Uint8Array]]: Uint8Array(355290) [82, 73, 70, …]
byteLength: (...)
__proto__: ArrayBuffer
```

### 2. audioタグに入れ音声を再生する
Javascriptでバイナリデータを扱うものの中に`Blob`があります。
これと`DataView`を使うことでオーディオデータを挿入することができます。

```index.js
window.onload = () => {
  const element = document.getElementById('myFile')
  const audioElement = document.getElementById('audio')
  const resultElement = document.getElementById('result')

  element.onchange = () => {
    // ファイルが選択されたか
    if (!element.value) return

    // FileReaderクラスに対応しているか
    if (!window.FileReader) return

    // 0番目を読み込む
    const file = element.files[0]
    if (!file) return

    const fileReader = new FileReader()

    fileReader.onload = () => {
      // fileReader.resultにデータが入る
      console.log(fileReader.result)

      const view = new DataView(fileReader.result)
      const audioBlob = new Blob([view], { type: 'audio/wav' })
      const myURL = window.URL || window.webkitURL
      audioElement.src = myURL.createObjectURL(audioBlob)
    }

    fileReader.readAsArrayBuffer(file)
  }
}
```
![音声読み込み](https://i.imgur.com/ME6gY7m.png)

音声の読み込みと再生ができるようになりました。(ちなみにダウンロードも出来ます)



### 3. .wavのデータを解析
.wavのデータはRIFFチャンクと呼ばれるブロック構造になっているため、フォーマットに合わせて読み込みを行います。
RIFFチャンクは基本的にfmtチャンクとdataチャンクの2つで構成されています。

![wav](https://camo.qiitausercontent.com/95d79f1a4e43dc3b07a8ae754c4d637edda2057e/687474703a2f2f70656f706c652e6563652e636f726e656c6c2e6564752f6c616e642f636f75727365732f656365343736302f46696e616c50726f6a656374732f66323031342f777a3233332f4543452532303437363025323046696e616c2532305265706f72742532302848544d4c292f4543452532303437363025323053746572656f253230506c617965725f66696c65732f524946462532305741564525323066696c65253230666f726d61742e6a7067)
(https://qiita.com/syuhei1008/items/0dd07489f58158fb4f83 より引用)

まずは読み込み用のメソッドを作ります。(window.onloadスコープ内)
`readWaveData()`を呼ぶことで波形データの配列を取得します。

```index.js
  // 指定したバイト数分文字列として読み込む
  const readString = (view, offset, length) => {
    let text = ''
    for (let i = 0; i < length; i++) {
      text += String.fromCharCode(view.getUint8(offset + i))
      console.log(text)
    }
    return text
  }

  // ビットレートが16bitのPCMとして読み込む
  const read16bitPCM = (view, offset, length) => {
    let input = []
    let output = []
    for (let i = 0; i < length / 2; i++) {
      input[i] = view.getInt16(offset + i * 2, true)
      output[i] = parseFloat(input[i]) / parseFloat(32768)
      if (output[i] > 1.0) output[i] = 1.0
      else if (output[i] < -1.0) output[i] = -1.0
    }
    return output
  }

  const readWaveData = view => {
    const riffHeader = readString(view, 0, 4) // RIFFヘッダ
    const fileSize = view.getUint32(4, true) // これ以降のファイルサイズ (ファイルサイズ - 8byte)
    const waveHeader = readString(view, 8, 4) // WAVEヘッダ

    const fmt = readString(view, 12, 4) // fmtチャンク
    const fmtChunkSize = view.getUint32(16, true) // fmtチャンクのバイト数(デフォルトは16)
    const fmtID = view.getUint16(20, true) // フォーマットID(非圧縮PCMなら1)
    const channelNum = view.getUint16(22, true) // チャンネル数
    const sampleRate = view.getUint32(24, true) // サンプリングレート
    const dataSpeed = view.getUint32(28, true) // バイト/秒 1秒間の録音に必要なバイト数(サンプリングレート*チャンネル数*ビットレート/8)
    const blockSize = view.getUint16(32, true) // ブロック境界、(ステレオ16bitなら16bit*2=4byte)
    const bitRate = view.getUint16(34, true) // ビットレート

    let exOffset = 0 //拡張パラメータ分のオフセット
    if (fmtChunkSize > 16) {
      const extendedSize = fmtChunkSize - 16 // 拡張パラメータのサイズ
      exOffset = extendedSize
    }
    const data = readString(view, 36 + exOffset, 4) // dataチャンク
    const dataChunkSize = view.getUint32(40 + exOffset, true) // 波形データのバイト数
    const samples = read16bitPCM(view, 44 + exOffset, dataChunkSize + exOffset) // 波形データを受け取る

    return samples
  }
```
補足ですが、`exOffset`をいれることでWAVEファイルにおける拡張パラメータの分だけずらして計算するようにしています。
[こちらの記事](http://d.hatena.ne.jp/uppudding/20071223/1198420222)がわかりやすくて参考になりました。

### 4. 結果をいい感じに表示する
いい感じにテーブルで表示するメソッドを作ります。

```index.js
window.onload = () => {
  /* 省略 */
  const renderTable = (fields, values) => {
    const table = document.createElement('table')
    const tbody = document.createElement('tbody')
    for (i = 0; i < fields.length; i++) {
      const tr = document.createElement('tr')
      for (j = 0; j < 2; j++) {
        const td = document.createElement('td')
        td.innerHTML = j == 0 ? fields[i] : values[i]
        tr.appendChild(td)
      }
      tbody.appendChild(tr)
    }
    table.appendChild(tbody)
    table.border = 1
    table.classList.add('result-table') // クラスを指定しcssを反映
    resultElement.appendChild(table)
  }

  const readWaveData = view => {
    /* 省略 */

    // 左のセルに表示するフィールド名
    const fields = [
      'RIFF',
      'FileSize',
      'WAVE',
      'fmt',
      'fmtChunkSize',
      'fmtID',
      'ChannelNumber',
      'SampleRate',
      'DataSpeed',
      'BlockSize',
      'BitRate',
      'ExtendedSize',
      'data',
      'DataChunkSize',
      'Samples'
    ]

    // 右のセルに表示する値
    const values = [
      riffHeader,
      fileSize,
      waveHeader,
      fmt,
      fmtChunkSize,
      fmtID,
      channelNum,
      sampleRate,
      dataSpeed,
      blockSize,
      bitRate,
      exOffset,
      data,
      dataChunkSize,
      samples
    ]

    renderTable(fields, values)

    return samples
  }

  element.onchange = () => {
    /* 省略 */

    fileReader.onload = () => {
      // fileReader.resultにデータが入る
      console.log(fileReader.result)

      const view = new DataView(fileReader.result)
      const audioBlob = new Blob([view], { type: 'audio/wav' })
      const myURL = window.URL || window.webkitURL
      audioElement.src = myURL.createObjectURL(audioBlob)

      const samples = readWaveData(view) // DataViewから波形データを読み込む
    }

    fileReader.readAsArrayBuffer(file)
  }
}

```
`index.html`にもstyleを追加

```index.html
<!DOCTYPE html>
<html>
  <head>
    <script src="./index.js"></script>
    <style>
      .tables {
        table-layout: fixed;
        width: 800px;
        word-wrap: break-word;
      }
    </style>
  </head>
  <body>
    <input id="myFile" type="file" /> <audio id="audio" controls></audio>
    <div id="result"></div>
  </body>
</html>
```


## 最終結果

![](https://i.imgur.com/mj4JVBm.gif)

<p data-height="265" data-theme-id="0" data-slug-hash="ZVKPbo" data-default-tab="js,result" data-user="konatsup" data-pen-title="ZVKPbo" class="codepen">See the Pen <a href="https://codepen.io/konatsup/pen/ZVKPbo/">ZVKPbo</a> by konatsu_p (<a href="https://codepen.io/konatsup">@konatsup</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

### 注意
今回は16bitPCM用の読み込みメソッドしか作っていないため、ビットレートが変わると波形データがうまく読み込めません。
また、fmtチャンクIDが`JUNK`になっている際も読み込めないので、読み込むデータに合わせて改善する必要があります。


## 参考
- https://www.html5rocks.com/ja/tutorials/file/dndfiles/
- https://qiita.com/syuhei1008/items/0dd07489f58158fb4f83
- https://numb86-tech.hatenablog.com/entry/2018/01/22/203406
- http://var.blog.jp/archives/62330155.html
- https://qiita.com/edo_m18/items/612d2b31498d22d13b7b
- https://lab.syncer.jp/Web/API_Interface/Reference/IDL/FileReader/readAsArrayBuffer/
- https://hakuhin.jp/js/file_reader.html#FILE_READER_RESULT
- http://d.hatena.ne.jp/uppudding/20071223/1198420222


## あとがき
波形データからピッチ(音程の高さ)抽出まで書く予定でしたが、時間がなかったので残念...
機会があればまた記事書きます。

(アドカレですが、私だけ遅刻してしまって本当に申し訳ない...)
Treasureで出会えた最高の仲間たちへ、メリークリスマス！
そして、良いお年を！