Visual Studio Code + TypeScript ＋ WebAudioAPIで波形表示 on Ubuntu 16.04


# はじめに

引きこもってないでQiitaになんか書いてみたらと勧められたので、
最近TypeScriptを触り始めたのもあり、練習がてらに記事にしてみました。

職場ではWidnows+VisualStudioがメインですが、どうせならと今回はUbuntuとVisual Studio Codeを使ってみました。

備忘録的な意味合いもあって、内容が冗長的です。
ご容赦ください。

<i class="fa fa-warning"> getUserMedia/Stream APIを使用しています。対応ブラウザは[こちら](http://caniuse.com/#feat=stream)を参考にしてください。  
なお動作確認はfirefoxでしか行っておりませんので悪しからず</i>

- 環境とか
    - Ubuntu16.04
    - Visual Studio Code 1.4.0
    - node.js package
        - typescript 1.8.10
        - typings 1.3.2
        - http-server 0.9
        - webrtc-adapter 2.0.2

## デモ＆コード

デモ：
https://sumishin.github.io/WebAudioAPI_Example/

コード：
https://github.com/sumishin/WebAudioAPI_Example

# 準備

GitHubから取得したリポジトリからビルドするのではなく、私が１から作成した時の健忘録のようなものです。    
興味がない場合は読み飛ばしてください。
→[実装へ移動](#実装)

## プロジェクト作成
以下のようなディレクトリを作成します。

```bash:terminal
$ tree example/
example/
├── built
└── ts
2 directories
```
## nodeモジュール設定
以下のようにnodeパッケージをインストールします。

```bash:terminal
$ npm init --yes
$ npm install typescript --save-dev
$ npm install typings --save-dev
$ npm install webrtc-adapter -save-dev
```
## 型定義を取得
typingsを使って型定義を取得します。

```bash:terminal
$ "$(npm bin)"/typings install dt~jquery --global --save
$ "$(npm bin)"/typings install dt~webaudioapi/waa --global --save
$ "$(npm bin)"/typings install dt~webrtc/mediastream --global --save
```
##  とりあえずのindex.html/app.ts作成
ルートにindex.html、tsディレクトリにapp.tsファイルを作成します。

```html:index.html
<!DOCTYPE html>
<html>
  <meta charset="UTF-8">
  <head><title>WebAudioAPI Example</title></head>
  <style>
  body {
    background-color: #000;
    color: #fff;
  }
  </style>
  <script src="https://code.jquery.com/jquery-3.1.0.js"
          integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
          crossorigin="anonymous"></script>  
  <script src='built/app.js'></script>
  <body>
      <h1>WebAudioAPI Example</h1>
      <section>
          <div style="text-align:center">
            <button id="startbutton">start</button>
          </div>
      </section>
  </body>
</html>
```
```ts:app.ts
/// <reference path="../typings/index.d.ts" />

jQuery(document).ready(() => {
    $('#startbutton').on('click',() => {
        window.alert('test');
    });
});
```
## tsconfig作成
webaudioapiでPromiseが使われているのでtargetをes6を指定します

```json:tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es6",
        "noImplicitAny": false,
        "outDir": "built/",
        "sourceMap": true
    },
    "exclude": [
        "node_modules"
    ]
}
```
## TypeScriptのコンパイルタスク追加
VSCodeのタスクランナーで設定ができますが、今回はプロジェクトのnode_modulesを使いたいのでnpmのスクリプトにしました。

1. package.jsonのscriptsを以下に書き換えます。

```json:package.json
"scripts": {
    "build": "tsc -w -p ."
}
```
1. VSCode上でF1キーを入力しコマンドパレットを表示します。
2. "npm run"と入力し、表示される候補から"npm: run npm script"を選びます。
3. "build"を選びます
./built/app.jsとapp.js.mapが生成されたことを確認します。

## httpサーバ&デバッグ設定

1. 以下のnodeパッケージをインストールします。

```bash:terminal
$ npm install http-server --save-dev
```
1. http-serverを起動するscriptを登録します。

```json:package.json
"scripts": {
    "build": "tsc -w -p .",
    "launch-http-server": "http-server -p 8080"
}
```
4. 以下を参考にDebugger for Chrome 拡張機能のインストールを行います。  
https://ics.media/entry/11356
5. F5キーを入力すると.vscode/launch.jsonが作成され、編集を行う旨のメッセージが表示されますが、そのままでOKです。
1. VSCode上でF1キーを入力しコマンドパレットを表示します。
2. "npm run"と入力し、表示される候補から"npm: run npm script"を選びます。
3. "launch-http-server"を選びます
6. http-serverの起動確認後、F5キーを入力するとchoromeが起動しindex.htmlが表示されることを確認します。

## gitリポジトリ設定

gitignoreの追加とディレクトリ内のファイルの登録を行います。

```bash:terminal
$ git init
$ git commit --allow-empty -m "first commit"
$ echo -e "node_modules/\ntypings/\nnpm-debug.log" > .gitignore
$ git add --all
$ git commit -m "initial settings"
```
## 準備完了
最終的に以下のようなディレクトリ構成になりました。

```bash:terminal
$ tree ../example -a -I ".git|node_modules|typings"
../example
├── .gitignore
├── .vscode
│   └── launch.json
├── built
│   ├── app.js
│   └── app.js.map
├── index.html
├── package.json
├── ts
│   └── app.ts
├── tsconfig.json
└── typings.json
3 directories, 9 files
```
# 実装

今回は以下の内容を実装します。

- クライアントのマシンに置かれた音声ファイルの読み込みと再生
- AnalyserNodeを使ったリアルタイムの音声分析
- iOSのボイスメモっぽく波形データを可視化
- マイクから取り込んだ音声の解析と可視化  
  
<img width="320" alt="ss.png" src="https://qiita-image-store.s3.amazonaws.com/0/139648/36fe80aa-db2a-7563-a5ce-ffc3db55eae0.png">

順番に解説しますがエラー処理やUI操作等は省略し、ポイントとなるような部分のみピックアップして記述しています。

## クライアントのマシンに置かれた音声ファイルの読み込みと再生

### 再生準備
WebAudioAPIを使うので、何はともあれAudioContextが必要です。
Appクラスを作成し、このクラスのコンストラクタでインスタンスを作ります。

```typescript:app.ts
class App {
    private _audioContext: AudioContext;
    constructor() {
        this._audioContext = new window.AudioContext();
    }
}
var app = new App();    
```
### ファイルの読み込み

File APIを使用します。
まずHTML側に音声ファイルの読み込みを行う為のフォームを作成します。

```html:index.html
<input id="audioFile" type="file" accept="audio/*" />
```
こいつのchangeイベントをハンドリングし、FileReaderを使って読み込みます。

```typescript:app.ts
$('#audioFile').on('change',(e: JQueryEventObject) => {
    let input:HTMLInputElement = <HTMLInputElement>e.target;
    if(0 < input.files.length) {
        let f:File = input.files.item(0);
        app.startByFile(f);
    }
}
class App {
    ...
    public startByFile(audioFile:File) { 
        let fr:FileReader = new FileReader();
        fr.addEventListener('load', ()=>{
            // 読み込み結果は以下にArrayBufferで格納されます。
            console.log(fr.result)
        }, false);
        fr.readAsArrayBuffer(audioFile);
    }
    ...
}
```
### 音声再生

次に読み込んだデータの音声再生を行います。
Decodeして結果をAudioBufferSourceNodeに設定して、AudioContextに接続、再生という流れです。

```typescript:app.ts
class App {
    public startByFile(audioFile:File) { 
        let fr:FileReader = new FileReader();
        fr.addEventListener('load', ()=>{
            // 読み込んだ音声ファイルのデコードを非同期で行う
            this.asyncDecodeAudioData(fr.result);
        }, false);
        fr.readAsArrayBuffer(audioFile);
    }
    private asyncDecodeAudioData(data:ArrayBuffer) {
        this._audioContext.decodeAudioData(data, (decodedData: AudioBuffer) => {
            // BufferSourceとScriptProcessorを作成
            this._audioFileSource = this._audioContext.createBufferSource();
            this._audioFileSource.buffer = decodedData;
            this._audioFileSource.connect(this._audioContext.destination);
            // 再生
            this._audioFileSource.start(0);
        }
    }        
}
```
## AnalyserNodeを使ったリアルタイムの音声分析

### AnalyserNodeの作成

音の分析を行うAnalyserNodeをAppクラスのコンストラクタで作成します。

```typescript:app.ts
class App {
    private _audioContext: AudioContext;
    private _analyser: AnalyserNode;
    constructor() {
        this._audioContext = new window.AudioContext();
        this._analyser = this._audioContext.createAnalyser();
    }
}
```
### AnalyserNodeとAudioBufferSourceNodeの接続

再生する音声を解析するため、再生前にAudioBufferSourceNodeをAnalyserNodeに接続します。

```typescript:app.ts
class App {
    ...
    private asyncDecodeAudioData(data:ArrayBuffer) {
        this._audioContext.decodeAudioData(data, (decodedData: AudioBuffer) => {
            // BufferSourceとScriptProcessorを作成
            this._audioFileSource = this._audioContext.createBufferSource();
            this._audioFileSource.buffer = decodedData;
            this._audioFileSource.connect(this._analyser);  // 接続
            this._audioFileSource.connect(this._audioContext.destination);
            // 再生
            this._audioFileSource.start(0);
        }
    }        
}
```
### ScriptProcessorNodeを使った分析結果の取り出し

これで音声再生中にAnalyserNodeで分析結果を取得することができるようになりました、簡単ですね。
次に具体的な解析結果の取り出しを行います。
タイマーなどで定期的に取り出すことも可能ですが、今回はScriptProcessorNodeを使ってバッファーが処理される度に分析結果を取得するようにしてみます。

- ScriptProcessorNodeとはスクリプトを使って直接バッファ操作を行う低レベルな処理を実装するためのAPIです。

ScriptProcessorNodeを作り、AudioContextに接続します。
またAnalyserNodeからScriptProcessorNodeへの接続も行います。
分析結果の取り出しはScriptProcessorNodeのonaudioprocessに設定したメソッドで行います。
今回は時間領域の波形データを取り出すことにしました。

```typescript:app.ts
class App {
    ...
    private asyncDecodeAudioData(data:ArrayBuffer) {
        this._audioContext.decodeAudioData(data, (decodedData: AudioBuffer) => {
            ...
            this._scriptProcessor = this._audioContext.createScriptProcessor(this._analyser.fftSize, 1, 1);
            this._analyser.connect(this._scriptProcessor);
            this._scriptProcessor.connect(this._audioContext.destination);
            this._scriptProcessor.onaudioprocess = () => this.onAudioProcess();
            
            // 再生
            this._audioFileSource.start(0);
        }
    }
    private onAudioProcess() {
        
        // 時間領域の波形データを取得します
        this._amplitudeArray = new Uint8Array(this._analyser.frequencyBinCount);
        this._analyser.getByteTimeDomainData(this._amplitudeArray);
        
        console.log(this._amplitudeArray);
    }
}
```
##  iOSのボイスメモっぽく波形データを可視化

波形データの取り出しまで進みました。
今度はこの波形データの描写を行います。

### Canvasの準備

波形データの表示にはCanvasを用います。
以下のようなcanvasをindex.htmlに追加してください。

```html:index.html
<canvas id="canvas" width="800" height="256" >
</canvas>
```
### 波形データを描写するためのクラス作成

波形描写は専用のクラスを作って行わせます。
描写処理自体は波形データ内の最大値と最小値を渡せば、その範囲を幅1px分塗りつぶして、次の呼び出しを待つという簡単な実装とします。

まずコンストラクタです。
canvas要素をもらって、Contextとサイズをメンバ変数として保持します。

```typescript:app.ts
class TimeDomainSummaryDrawer {
    private _context: CanvasRenderingContext2D;
    private _width: number;
    private _height: number;
    private _currentX: number;

    constructor(canvas:HTMLCanvasElement) {
        this._context = canvas.getContext('2d');
        this._width = canvas.width;
        this._height = canvas.height;
        this._currentX = 0;
    }
}    
```
次に描写を行うためのメソッド実装です。

渡された最大値と最小値から塗りつぶすy軸の領域を求めます。
無音の場合があるので薄い色で中央を1pxだけ塗りつぶし、算出した領域の塗りつぶしを行い、x軸を移動します。

```typescript:app.ts
class TimeDomainSummaryDrawer {
    ...
    public draw(low:number, hi:number){

        // 波形データは, 0 ～ 255, で表現されています。 
        // 振幅が1で考えると, 1が255, 0 (無音) が128, -1が0に対応しています。
        // この関係に基づき、以下の様に塗りつぶす領域を求めます。
        var drawLow = this._height - (this._height * (low / 256));
        var drawHi = this._height - (this._height * (hi / 256));
        
        // x位置が幅を超えているようならクリア
        if(this._width < this._currentX) {
            this.clear();
        }

        // 現在位置の中央部を描写
        this._context.fillStyle = '#cccccc';
        this._context.fillRect(this._currentX, this._height / 2, 1, １);

        // summary描写
        this._context.fillStyle = '#ffffff';
        this._context.fillRect(this._currentX, drawLow, 1, drawHi - drawLow);

        // x位置更新
        this._currentX++;
    }
}    
```
最後にcanvasをクリアするメソッドです。

```typescript:app.ts
class TimeDomainSummaryDrawer {
    ...
    public clear() {
        this._context.clearRect(0, 0, this._width, this._height);
        this._currentX = 0;
    }
}
```
Appクラスのコンストラクタでこのクラスのインスタンスを作成するようにします。

```typescript:app.ts
class App {
    ...
    private _drawer: TimeDomainSummaryDrawer; 
    constructor(canvas:HTMLCanvasElement) {
        ...
        // 描写を行うクラス
        this._drawer = new TimeDomainSummaryDrawer(canvas); 
    }
}
var c: HTMLCanvasElement = <HTMLCanvasElement>$('#canvas').get(0)
var app = new App(c);    
```
### requestAnimationFrameを使ったCanvasへの書き込み

次にAppクラスからTimeDomainSummaryDrawerクラスのdrawメソッドを呼び出す処理を実装します。
描写はdrawメソッドを直接呼び出すのではなく、requestAnimationFrameから行わせます。

```typescript:app.ts
class App {
    ...
    private onAudioProcess() {
    
        // 時間領域の波形データを取得します
        this._amplitudeArray = new Uint8Array(this._analyser.frequencyBinCount);
        this._analyser.getByteTimeDomainData(this._amplitudeArray);
        
        if(!!this._animationID) {
            window.cancelAnimationFrame(this._animationID);
        }
        this._animationID = window.requestAnimationFrame(() => {
            this.drawTimeDomain();
            delete this._animationID;
        });
    }
    private drawTimeDomain() {
    
        var minValue:number = Number.MAX_VALUE;
        var maxValue:number = Number.MIN_VALUE;

        // 現時点の波形データの最大値と最小値を取り出します。
        for (var i = 0; i < this._amplitudeArray.length; i++) {
            var value = this._amplitudeArray[i];
            if(value > maxValue) {
                maxValue = value;
            } else if(value < minValue) {
                minValue = value;
            }
        }

        // 波形データの最小値と最大値を指定し、drawerに描写させます。
        this._drawer.draw(minValue, maxValue);
    }
}
```
これで波形データがCanvasに表示されるようになりました。

## マイクから取り込んだ音声の解析と可視化

次に分析を行う対象を再生中の音声からマイクの音声に変更します。

### WebRTC Adapterの適用

マイクから音を得るのにはWebRTC APIを使用します。
2016年9月現在では、まだ各社で実装がまちまちでクロスブラウザ対応が必要な状況となっている為、WebRTC projectが提供してくれているadapter.jsを使います。
https://github.com/webrtc/adapter

準備手順でwebrtc-adapterのnode.jsパッケージを導入済みなので、htmlで以下のようにscriptを参照する様にします。

```html:index.html
  <script src='node_modules/webrtc-adapter/out/adapter.js'></script>
```
（本来であればタスクランナー等で然るべき場所にコピーして利用すべきところですがサンプルなのでご容赦を。。。）

### getUserMediaでマイクのMediaStreamを得る

getUserMediaを使い、マイクのMediaStreamを取得します。
実行時にブラウザ上でマイクへの接続許可が求められますので承諾してください。

```typescript:app.ts
class App {
    ...
    public startByUserMeida() { 
        var constraints: MediaStreamConstraints =  { audio: true, video: false };
        navigator.mediaDevices.getUserMedia(constraints)
            .then(stream => {
                conslole.log(stream);
            });
    }
    ...
}    
```

### MediaStreamAudioSourceNodeの作成

MediaStreamからMediaStreamAudioSourceNodeを作成し、AnalyserNodeへの接続とScriptProcessorNodeの作成、接続を行います。
AudioContextに接続しない点を除けば、ほぼAudioBufferSourceNodeと同じです。

```typescript:app.ts
class App {
    ...
    public startByUserMeida() { 
        var constraints: MediaStreamConstraints =  { audio: true, video: false };
        navigator.mediaDevices.getUserMedia(constraints)
            .then(stream => {
                // MediaStreamSourceとScriptProcessorを作成
                this._microphone = this._audioContext.createMediaStreamSource(stream);
                this._microphone.connect(this._analyser);

                this._scriptProcessor = this._audioContext.createScriptProcessor(this._analyser.fftSize, 1, 1);
                this._analyser.connect(this._scriptProcessor);
                this._scriptProcessor.onaudioprocess = () => this.onAudioProcess();
            });
    }
    ...
}    
```
これでマイクの音を分析して音声再生時と同じように波形が表示されるようなったと思います。

## まとめ

TypeScriptでもWebAudioAPIは問題なく使えそうです。
VSCodeの支援も手厚く、楽に実装できた気がします。
またgh-pagesを使うことで公開もgithubで完結できたのが地味に助かりました。

２、３日でさくっと記事にしようと思ったのですが、npmやtypescriptの環境周りの調査や検証に時間がかかり、公開まで半月ほどかかってしまいました。  
これにめげずに今後も色々書いてみようと思います。