PCのマイク音声をパペロでストリーミング再生する

 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サーバが必要になりますが今回もGolangGorilla 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であれば動作します。