Web Audio API で取得したユーザ音声を Node.js でリアルタイム処理

Web Audio API を使用して録音されるユーザの音声をリアルタイムにサーバに送信し、音声認識などの処理を逐次行うケースを想定しています。

今回作成したソースは [GitHub](https://github.com/ryojiysd/node-audio-stream) に置いてあります。

# 動作確認環境

- macOS Mojave
- Node.js 11.6.0
- Express.js 4.16.4
- Google Chrome 73

# 1. 必要なパッケージのインストール

はじめに今回使用するパッケージのインストールを行います。
サーバとして Express を、サーバ・クライアント間の通信に socket.io を、最後に受信したバッファを WAVE ファイル形式で保存する為に wav-encoder を使用しています。

```sh
$ npm init
$ npm install --save express
$ npm install --save socket.io
$ npm install --save socket.io-client
$ npm install --save wav-encoder
```

# 2. サーバ側の実装

はじめに HTTP サーバを起動します。
なお、localhost 以外からサーバにアクセスする場合には HTTPS が必要となりますのでご注意ください。

```js
const express = require('express')
const http = require('http')
const path = require('path')
const app = express()

app.use('/', express.static(path.join(__dirname, 'public')))

server = http.createServer(app).listen(3000, function() {
    console.log('Example app listening on port 3000')
})
```

次に、WebSocket サーバを起動し、クライアントが接続したときの処理を記述します。今回はクライアントから受け付けるメッセージとして以下の3種類を定義します。

| メッセージ | 説明                        |
| -------- | -------------------------- |
| start    | 録音開始の合図。サンプリング周波数を受け取る。               |
| send_pcm | 録音中のPCMデータを受け取るAPI。 |
| stop     | 録音停止の合図を受け取るAPI。受信した音声を public/wav の下に保存する。 |

今回の例では省略していますが、リアルタイムに音声認識などの処理を行いたい場合には `send_pcm` の中で行うことになります。

```js:app.js
// WebSocket サーバを起動
const socketio = require('socket.io')
const io = socketio.listen(server)


// クライアントが接続したときの処理
io.on('connection', (socket) => {
    let sampleRate = 48000
    let buffer = []

    // 録音開始の合図を受け取ったときの処理
    socket.on('start', (data) => {
        sampleRate = data.sampleRate
        console.log(`Sample Rate: ${sampleRate}`)
    })

    // PCM データを受信したときの処理
    socket.on('send_pcm', (data) => {
        // data: { "1": 11, "2": 29, "3": 33, ... }
        const itr = data.values()
        const buf = new Array(data.length)
        for (var i = 0; i < buf.length; i++) {
            buf[i] = itr.next().value
        }
        buffer = buffer.concat(buf)
    })

    // 録音停止の合図を受け取ったときの処理
    socket.on('stop', (data, ack) => {
        const f32array = toF32Array(buffer)
        const filename = `public/wav/${String(Date.now())}.wav`
        exportWAV(f32array, sampleRate, filename)
        ack({ filename: filename })
    })
})
```

クライアントから送られてくる PCM データは以下のオブジェクトの形式になっているため、配列に変換して `buffer` に格納しています。

```json
{ "1": 11, "2": 29, "3": 33, ... }
```

WAVE ファイルへの書き出しは WavEncoder を使用しています。WavEncoder へ渡すデータは Float32Array である必要があるため、下記の関数で変換を行っています。

```js:app.js
// Convert byte array to Float32Array
const toF32Array = (buf) => {
    const buffer = new ArrayBuffer(buf.length)
    const view = new Uint8Array(buffer)
    for (var i = 0; i < buf.length; i++) {
        view[i] = buf[i]
    }
    return new Float32Array(buffer)
}
```

```js:app.js
const WavEncoder = require('wav-encoder')
const fs = require('fs')

// data: Float32Array
// sampleRate: number
// filename: string
const exportWAV = (data, sampleRate, filename) => {
    const audioData = {
        sampleRate: sampleRate,
        channelData: [data]
    }
    WavEncoder.encode(audioData).then((buffer) => {
        fs.writeFile(filename, Buffer.from(buffer), (e) => {
            if (e) {
                console.log(e)
            } else {
                console.log(`Successfully saved ${filename}`)
            }
        })
    })
}
```

# 3. クライアント側の実装

はじめにサンプリング周波数を `start` を指定してサーバに送信します。

次に、`getUserMedia` を使用してマイクにアクセスします。アクセス後は `processor.onaudioprocess` にコールバック関数を指定することで、リアルタイムに音声を取得し、 `send_pcm` を指定してサーバに送信しています。

```js:public/javascripts/client.js
const socket = io.connect()
let processor = null
let localstream = null

function startRecording() {
    console.log('start recording')
    context = new window.AudioContext()
    socket.emit('start', { 'sampleRate': context.sampleRate })

    navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((stream) => {
        localstream = stream
        const input = this.context.createMediaStreamSource(stream)
        processor = context.createScriptProcessor(4096, 1, 1)

        input.connect(processor)
        processor.connect(context.destination)

        processor.onaudioprocess = (e) => {
            const voice = e.inputBuffer.getChannelData(0)
            socket.emit('send_pcm', voice.buffer)
        }
    }).catch((e) => {
        // "DOMException: Rrequested device not found" will be caught if no mic is available
        console.log(e)
    })
}
```

録音終了時には `getUserMedia` を停止させ、サーバに対して `stop` を送信します

```js:public/javascripts/client.js
function stopRecording() {
    console.log('stop recording')
    processor.disconnect()
    processor.onaudioprocess = null
    processor = null
    localstream.getTracks().forEach((track) => {
        track.stop()
    })
    socket.emit('stop', '', (res) => {
        console.log(`Audio data is saved as ${res.filename}`)
    })
}
```

最後にテスト用として録音の開始・停止ボタンのみの画面を作成しておきます。

```html:public/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <script src="/socket.io/socket.io.js" ></script>
    <script src="./javascripts/client.js" ></script>
  </head>
  <body>
    <button onClick="startRecording()">start</button>
    <button onClick="stopRecording()">stop</button>
  </body>
</html>
```

# 4. 動作確認

以下のコマンドでサーバを起動してブラウザから  `localhost:3000` にアクセスして、"start" ボタンをクリックしてマイクへ音声を入力後 "stop" ボタンをクリックすると、`public/wav` フォルダ内に WAVE ファイルが保存されます。

```sh
$ node app.js
```