Posted at

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

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

今回作成したソースは GitHub に置いてあります。


動作確認環境


  • macOS Mojave

  • Node.js 11.6.0

  • Express.js 4.16.4

  • Google Chrome 73


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

はじめに今回使用するパッケージのインストールを行います。

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

$ 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 が必要となりますのでご注意ください。

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 の中で行うことになります。


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 に格納しています。

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

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


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)
}


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 を指定してサーバに送信しています。


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 を送信します


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}`)
})
}

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


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 ファイルが保存されます。

$ node app.js