PaPeRo iのマイク音声をPCのWebブラウザでモニタすることができたので、逆にPCのマイク音声をWebブラウザからパペロに送ってストリーミング再生する方法について調べました。
ブラウザでマイクの音声データを取得する
ブラウザでマイクの音声データを取得してWebSocketで送信する事は、こちらのQiita記事で紹介されている16行ほどのJavaScriptプログラムで実現できます。これはWeb Audio APIを利用する方法です。
接続するパペロのIPアドレス指定と接続・切断の制御のためのチェックボックス、データ型の変換機能を追加したところ以下の様なHTML/JavaScriptになりました。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script>
'use strict';
(function() {
// Float32ArrayをUint8Arrayに変換する
var float32toUint8Array = function(f32) {
var res = new Uint8Array(f32.length);
for (var j = 0; f32.length > j; j++) {
res[j] = 128 * f32[j] + 128;
}
return res;
}
var waitdat;
// websocketへの送信
var wssend = function(ws, dat) {
if (!ws) {
// 接続が無い
return 5;
}
var res = ws.readyState;
if (1 == res) {
// 接続中
if (waitdat) {
// 待ちデータがあれば送信
ws.send(waitdat);
waitdat = null;
}
// データ送信
ws.send(dat);
} else if (0 == res) {
// websocket接続待ち:
// 最初のデータだけ保持してあとは捨てる
if (!waitdat) {
console.log('reserve data in connecting');
waitdat = dat;
} else {
console.log('discard data in connecting');
}
}
return res;
}
// 音声データ送信開始
var audioSend = function(paperoip) {
var is1st = true;
var ws;
var handleSuccess = function(stream) {
var context = new AudioContext();
var input = context.createMediaStreamSource(stream)
var processor = context.createScriptProcessor(1024, 1, 1);
input.connect(processor);
processor.connect(context.destination);
//var ws = new WebSocket('ws://192.168.1.1:8864/speaker');
var url = 'ws://' + paperoip + ':8864/speaker'
ws = new WebSocket(url);
processor.onaudioprocess = function(e) {
if (is1st) {
console.log("sample rate:" + e.inputBuffer.sampleRate);
is1st = false;
var rate = new Uint8Array(4);
var val = e.inputBuffer.sampleRate;
// サンプルレートを4byteリトルエンディアンで送る
rate[0] = 0xff & val;
rate[1] = 0xff & (val>>8);
rate[2] = 0xff & (val>>16);
rate[3] = 0xff & (val>>24);
if (wssend(ws, rate) > 1) {
processor.onaudioprocess = null;
}
}
var voice = e.inputBuffer.getChannelData(0);
//connection.send(voice.buffer); // websocketで送る
var dat = float32toUint8Array(voice);
if (wssend(ws, dat) > 1) {
processor.onaudioprocess = null;
}
};
};
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
.then(handleSuccess)
// 停止関数を返す
var stopf = function() {
if (ws && (ws.readyState <= 1)) {
ws.close();
ws = undefined;
}
}
return stopf;
};
var init = function() {
var chk = document.getElementById('startchk');
var paperoip = document.getElementById('paperoip');
var stopf;
if ((!chk)||(!paperoip)) {
window.alert('illegal id');
} else {
chk.addEventListener('change', function(e) {
if (e.target.checked) {
if (!paperoip.value) {
window.alert('bad papero ip');
chk.checked = false;
} else {
// チェックされたら音声転送開始
stopf = audioSend(paperoip.value);
}
} else {
// チェック外されたら音声転送停止
if (stopf) {
stopf();
stopf = undefined;
}
}
}, false);
}
}
window.addEventListener('load', init, false);
})();
</script>
</head>
<body>
<span>パペロIPアドレス</span>
<input type="text" id="paperoip" value="192.168.1.1">
<input type="checkbox" id="startchk">
<label for="startchk">ブラウザ音声送信開始</label>
</body>
</html>
- 音声データは32bit浮動小数から8bit符号なし整数に変換しています。
- 接続の最初にサンプルレートを4バイトで送っています。これはサンプルレートはプログラムからは指定できず、使用するマイクや設定により変わる場合があるためです。
これをひとまずPCのファイルとして保存してください。
パペロで受信した音声データをストリーミング再生する
パペロ側はWebSocketサーバが必要になりますが今回もGolangとGorilla WebSocketを使用しました。
- 安定動作のため受信データを少しためてから再生開始する
- 接続の最初に送られるサンプルレートを指定してaplayコマンドを起動して再生開始、以後受信した音声データは標準入力でaplayに送る
- aplayコマンド実行はgoroutineで行いWebSocket受信処理からデータをchannelで送る
という様にしました。なおこれはパペロ固有の機能は使用しておらず普通のLinuxマシンでも同じ様に動作します。
// remote speaker ws server
//
// (c) 2018 Sophia Planning Inc.
// LICENSE: MIT
package main
import (
"bufio"
"context"
"encoding/binary"
"flag"
"io"
"log"
"net/http"
"os/exec"
"strconv"
"github.com/gorilla/websocket"
)
// AudioStreamPlayer audio stream player by aplay command
type AudioStreamPlayer struct {
stopflag bool
ch chan []byte
}
// NewAudioStreamPlayer new AudioStreamPlayer
func NewAudioStreamPlayer() *AudioStreamPlayer {
s := &AudioStreamPlayer{}
s.stopflag = false
s.ch = make(chan []byte, 10)
return s
}
// Kill kill command and goroutine
func (s *AudioStreamPlayer) Kill() {
s.stopflag = true
s.ch <- []byte{}
}
// Put put data
func (s *AudioStreamPlayer) Put(dat []byte) {
s.ch <- dat
}
// Loop main loop
func (s *AudioStreamPlayer) Loop(delayms int) {
ctx := context.Background()
is1st := true
ispre := true
bidx := 0
buf := make([][]byte, 10)
var summs float64 = 0.0
var cmd *exec.Cmd
var inpipe io.WriteCloser
var errpipe io.ReadCloser
var rate uint32
for {
d := <-s.ch
if s.stopflag {
break
}
if is1st {
if len(d) == 4 {
rate = binary.LittleEndian.Uint32(d)
rates := strconv.Itoa(int(rate))
log.Println("received rate:", rate, rates)
cmd = exec.CommandContext(ctx, "/usr/bin/aplay", "-r", rates, "-f", "U8")
var err error
inpipe, err = cmd.StdinPipe()
if err != nil {
log.Println("cmd.StdinPipe error:", err)
}
errpipe, err = cmd.StderrPipe()
if err != nil {
log.Println("cmd.StderrPipe error:", err)
}
cmd.Start()
} else {
log.Println("illegal rate data length", len(d))
}
is1st = false
} else if ispre {
if (bidx >= len(buf)) || (summs >= float64(delayms)) {
log.Println("start playing")
for j := 0; j < bidx; j++ {
inpipe.Write(buf[j])
}
ispre = false
// check error
sc := bufio.NewScanner(errpipe)
go func() {
for sc.Scan() {
log.Print(sc.Text())
}
}()
log.Println("start streaming")
} else {
buf[bidx] = d
bidx++
if 0 < rate {
curms := 1000.0 / float64(rate) * float64(len(d))
summs += curms
log.Println("add buffer. cur:", curms, "sum:", summs)
}
}
}
if !ispre {
inpipe.Write(d)
}
}
log.Println("player exiting")
close(s.ch)
s.ch = nil
cmd.Process.Kill()
cmd.Wait()
log.Println("player exit")
}
// SpeakerHandler speaker server handler
func genSpeakerHandler(delayms int) func(w http.ResponseWriter, r *http.Request) {
res := func(w http.ResponseWriter, r *http.Request) {
var upgrader = websocket.Upgrader{}
upgrader.CheckOrigin = func(r *http.Request) bool {
return true
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
}
defer ws.Close()
log.Printf("ws connect %p\n", ws)
player := NewAudioStreamPlayer()
go player.Loop(delayms)
for {
_, dat, err := ws.ReadMessage()
if err != nil {
log.Println("ws read error:", err)
player.Kill()
break
}
player.Put(dat)
}
}
return res
}
func main() {
var addr = flag.String("addr", ":8864", "http service address")
var path = flag.String("path", "/speaker", "path")
var delayms = flag.Int("delay", 50, "initial delay in ms")
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile)
log.Println("start server. add:", *addr, " path:", *path, " delayms:", *delayms)
speakerHandler := genSpeakerHandler(*delayms)
http.HandleFunc(*path, speakerHandler)
http.ListenAndServe(*addr, nil)
}
PaPeRo i向けにビルドするにはWindowsであれば
> set GOOS=linux
> set GOARCH=arm
> go build
Linux(sh)であれば、
$ GOOS=linux GOARCH=arm go build
として下さい。Debian9 / go1.8.1で動作確認しています。
動作確認
PaPeRo iへビルドしたバイナリを転送し任意の場所で実行して下さい。
PCで保存した上記htmlファイルをChromeで開き、チェックボックスにチェックを入れ、マイク使用を許可すると指定したパペロへのマイク音声データ送信が始まり、パペロのスピーカーで再生されます。パペロの音がPCのマイクに届くとループになってハウリングしてしまうのでご注意ください。
なおこのhtmlをパペロ上に置いた場合、Chromeでは動作しません(httpsが必要)がEdgeであれば動作します。