53
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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
53
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?