blog.takurinton.dev

Skywayでビデオ通話

2020-10-09

はじめに

こんにちは、ブログに MaterialUIを導入してルンルンの です。
今回はSkyWayで1対1の音声通話、またそれを文字起こしをしてGASに投げる処理までを書いたのでまとめたいと思います。
GitHub Pages でデプロイしたサイトは こちら になります。
レポジトリは こちら になります。

SkyWayって何?

SkyWayとは、Webでリアルタイムコミュニケーションを実現する標準技術、WebRTC(Web Real Time Communication)をかんたんにアプリに導入できるSDK&APIです。( こちら から引用)

WebRTCが簡単に導入できるとはとても便利で楽しいサービスですよね!ドキュメントを参照すれば、Peer to Peerの音声ビデオチャットやテキストチャットなどの機能も簡単に実装・拡張することができます。

Reactのアプリケーションを作成する

今回はサクッといきたいのでcreate-react-appで作成したいと思います。

create-react-app textapp
cd textapp
npm start

おなじみのReactの画面が表示されればオッケーです!

1対1の通話機能を実装する

まずは1対1の通話機能を実装していきたいと思います。
今回はRoom.tsxというファイルを作成してそこに書き込んでいきたいと思います。

npm install skyway-js
// Room.tsx

import React, { useState, useRef, useEffect , FunctionComponent } from 'react'
import Peer from 'skyway-js'
import { RecognitionEffect } from '../recognition/Recognition'
import Button from '@material-ui/core/Button';
import Input from '@material-ui/core/Input'
import Box from '@material-ui/core/Box'
import { Typography } from '@material-ui/core'

import { RoomStyle, HomeStyle } from '../style/theme' // スタイル(別定義)

const Room:FunctionComponent<{}> = () => {
    const classes = RoomStyle()
    const inputButton = HomeStyle()
    const peer: Peer = new Peer({ key: YOUR_API_KEY })
    const [myId, setMyId] = useState<string>('')
    const [callId, setCallId] = useState<string>('')
    const me: React.MutableRefObject<any> = useRef(null)
    const to: React.MutableRefObject<any>  = useRef(null)
    const recognition = useRef<RecognitionEffect>();
    const [progress, setProgress] = useState("");
    
    const openCall = () => {
      peer.on('open', () => {
        setMyId(peer.id)
        navigator.mediaDevices.getUserMedia({ 
          video: true, 
          audio: true 
        })
        .then(localStream => {
          me.current.srcObject = localStream
        })
      })
      
      peer.on('call', mediaConnection => {
        mediaConnection.answer(me.current.srcObject)
        mediaConnection.on('stream', async stream => {
          to.current.srcObject = stream
        })
      })
    }

    const makeCall = () => {
      const mediaConnection = peer.call(callId, me.current.srcObject)
      mediaConnection.on('stream', async stream => {
        to.current.srcObject = stream
        await to.current.play().catch(console.error)
      })
    }

    useEffect(( ) => {
      openCall()
    }, [])

    return (
      <Box className={classes.root}>
        <Box className={classes.room}>
          <Box className={classes.camera}>
            <Typography variant="h4">
              You: {typeof n === undefined ? '' : n}
            </Typography>
              <video width="400px" autoPlay muted playsInline ref={me} />
              <p>Your ID : {myId}</p>
          </Box>
          <Box className={classes.camera}>
          <Typography variant="h4">
            Your friend
          </Typography>
            <video width="400px" autoPlay muted playsInline ref={to}/>
          </Box>
        </Box>
        <Box className={classes.inputID} >
          <Input value={callId} 
                    onChange={e => setCallId(e.target.value)} 
                placeholder="通話相手のIDを入力してください" 
                className={inputButton.inputText}
           />
          <Button onClick={makeCall} 
        variant="contained" 
        color="primary" 
        className={inputButton.button} >
            発信
        </Button>
        </Box>
      </Box>
    )
}

export default Room

こんな感じで実装してあげると相手のIDをテキストボックスに入れてボタンを押すと通話が開始されます。使う前にカメラとマイクをオンにしてあげてくださいね〜。 ブラウザによってはうまく動作しないかもしれないです。
次に細かい部分についての説明をしていきます。

接続する

接続するためにはAPI Keyが必要です。取得してない人は 公式サイト から取得しましょう。 API Keyを取得したあとは、Peerオブジェクトの引数としてkeyを渡すとpeerにはユーザーの情報が格納されます。

const peer: Peer = new Peer({ key: YOUR_API_KEY })

電話をかける

次に先ほど格納したものを使用して相手に電話をかける処理を実装したいと思います。
peerのイベントとして、openというイベントがあります。openがハッカしたときに自分のidを取得してストリームによってメディア通信が確立されます。また、今回は実装していませんが、closeイベントが走ると部屋を閉じることができます。

const openCall = () => {
      peer.on('open', () => {
        setMyId(peer.id)
        navigator.mediaDevices.getUserMedia({ 
          video: true, 
          audio: true 
        })
        .then(localStream => {
          me.current.srcObject = localStream
        })
    }
) 

相手と接続する

先ほどは自分の通信を確立しましたが、今度は相手と接続をして実際に通話を開始する部分についてです。
ここでは、自分のidを使用して相手と接続します。接続が成功したときには相手のストリームがセットされ接続が確立されます。

peer.on('call', mediaConnection => {
        mediaConnection.answer(me.current.srcObject)
        mediaConnection.on('stream', async stream => {
          to.current.srcObject = stream
        })
      })
    }

ビデオに写す

ビデオに写すときにはhtmlのビデオタグを使用します。
refに先ほどセットしたストリームを渡すことでお互いの画面がリアルタイムで共有され、通話をすることができるということです。

.
.
.
 <video width="400px" autoPlay muted playsInline ref={me}/>
.
.
.
 <video width="400px" autoPlay muted playsInline ref={to}/>
.
.
.

音声を読み取る機能を実装する

次は先ほどまで書いていたスクリプトに音声認識の機能を実装したいと思います。
今回は標準で装備されてるWeb Speech API Speech Recognitionを使用して実装を行いたいと思います。

Web Speech API Speech Recognitionの簡単な使い方をまずはReactではなく素のhtmlで確認してみます。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <h2>speech to text sample</h2>
    <button id="btn">start</button>
    <div id="content"></div>

<script>
    const speech = new webkitSpeechRecognition();      
    speech.lang = 'ja-JP';

    const btn = document.getElementById('btn');
    const content = document.getElementById('content');

    btn.addEventListener('click' , function() {
        speech.start();
    });

    speech.addEventListener('result' , function(e) {
    console.log(e.results[0][0].transcript)
    content.innerHTML = e.results[0][0].transcript
    })
    

</script>
<body>
<html>

これでブラウザを開いて何か喋ると喋った言葉がフロントエンドに表示されると思います。動作確認は完了です。 これをもとに先ほどのRoom.tsxにコードを追加していきます。
また、今回は受け取った音声をGASに投げる処理を行いたいのでそのためのhooksとAPIを叩くための関数も定義しておきます。

Recognition.ts

音声認識をする部分をReactComponentにベタベタ書いていくのは気が引けたのでここだけ別定義しました。(すでにサボり気味で汚いコードになってはいますが)
Recognition.tsでは音声認識の開始と終了の判断、またその切り替えをする関数であるtoggleを定義しています。文脈の区切りで勝手に区切ってくれます。これはとても便利。

// Recognition.ts

export class RecognitionEffect {
    r: SpeechRecognition = new (window as any).webkitSpeechRecognition();
    running = false;
  
    onFinal?: (c: string) => void;
    onProgress?: (c: string) => void;
    onError?: () => void;
  
    constructor() {
      this.r.continuous = true;
      this.r.interimResults = true;
  
      this.r.onresult = (e) => {
        for (let i = e.resultIndex; i < e.results.length; ++i) {
          if (e.results[i].isFinal) {
            if (this.onFinal){
        this.onFinal(e.results[i][0].transcript);
        }            
          } else {
            // eslint-disable-next-line no-lonely-if
            if (this.onProgress){
        this.onProgress(e.results[i][0].transcript);
            }    
          }
        }
      };
  
      this.r.onerror = (err) => {
        console.warn(err);
        if (this.onError) this.onError();
      };
      this.start();
    }
  
    start() {
      this.running = true;
      this.r.start();
    }
  
    stop() {
      this.running = false;
      this.r.stop();
    }
  
    // start stop 切り替え 
    toggle() {
      if (this.running) {
        this.stop();
      } else {
        this.start();
      }
  }
}

音声を取得してリクエストを投げる

次はRoom.tsxです。 ここにはRecognitionEffect()オブジェクトを新しく生成し、rという変数に代入しています。
先ほど定義したtoggle関数を使用して音声認識のオンオフを切り替え、そこのステートが変わったとき、つまり文章が途切れたときにGASへリクエストを送るようにしています。

// Room.tsx

import React, { useState, useRef, useEffect , FunctionComponent } from 'react'
.
.
.
const Room:FunctionComponent<{}> = () => {
    .
    .
    .
    // これを追加
    useEffect(() => {
      const r = (recognition.current = new RecognitionEffect());
      r.onFinal = (c) => {
        r.toggle()
        setSpeech(c)
        const userName = name
        state.name = userName
        state.contents = c
        handleSubmit(state)
      }
      r.onError = () => {      
        console.log('error')
      };
      r.onProgress = setProgress;
    }, [speech]);
    
    return (
      .
      .
      .                
}

export default Room

hooksで状態を管理する

ここはいうまでもないと思うのですが、hooksを使用して状態を管理しています。nameというのは名前を格納するための箱です。名前ってなんだよって思ったら 全体のコード を是非見てみてください。
handleSubmitでpostTextという次に定義する関数を使用してリクエストを投げています。

// hooks
// useText.ts

import React, { useState } from 'react'

import postText from '../api/postText'

interface Body {
    name: string
    contents: string 
}

const initialState:Body = {
    name: "", 
    contents: ""
}

const useText = () => {
    const [state, setState] = useState<Body>(initialState)

    const handleSubmit = (body:Body) => {
        postText(body)
        setState(initialState)
    }

    return {
        state, 
        handleSubmit
    }
}

export default useText

APIを叩く

APIを叩くための関数をここで定義しました。
APIを叩くためにfetch APIを使用して定義をしました。GASのURLは次で定義をします。fetch APIによって得られたレスポンスをreturnして次に渡しています。レスポンスは特にないので実はいらないかも。

// post api
// postText.ts

interface Body {
    name:string
    contents:string
}

const postText = async (body: Body) => {
    await fetch(`あなたのGASのURL`, {
        method: "POST",
        credentials: "same-origin",
        body: JSON.stringify(body), 
    })
}

export default postText

GASを書く

ようやくReact側が温まってきたのでGASの方を描いていきたいと思います。 GASはほんとに超シンプルで、以下のような感じで書きました。

const doPost = (e) => {
  var j = e.postData.contents;
  var d = JSON.parse(j)
  writeToSheet(d.name, d.contents)
}

const writeToSheet = (name, contents) => {
  var ss = SpreadsheetApp.getActive();
  var sheet = ss.getActiveSheet();
  sheet.appendRow([new Date(), name, contents]);
}

GASではGETリクエストを受け取るときはdoGet、POSTリクエストを受け取るときはdoPostというメソッドを作成してやりとりを行います。
GAS独自の関数などがありますが、あまり気にする必要はなく、コピペで十分です。

まとめ

Skywayの存在は知っていましたがしっかり触ったことがなかったので今回1人でしっかり作ってみて意外と楽しいしめちゃくちゃ簡単にpeer to peer のビデオ通話が実現できて感動しています。
また、音声認識や文字起こしもこちら側がほぼ苦労せずに実装することができるので人類の進化はすごいなと思いました。

最後になりますがこのプログラム自体は2日くらいで作ったものです。また、この記事は深夜に書いたものです。不備がある場合は連絡してくれたり、プルリクを出してくれるととても喜びます。
だらだらと書いていきましたが、最後まで読んでいただきありがとうございました。以上!

おまけ

関係ないけど今回使ったMaterialUIのtheme

import { 
        createStyles, 
        makeStyles, 
        Theme, 
        createMuiTheme 
    } from '@material-ui/core/styles'

export const theme = createMuiTheme({
    palette: {
        primary: {
          light: '#ffff8b',
          main: '#81c784',
          dark: '#c9bc1f',
          contrastText: '#000000',
        },
        secondary: {
          light: '#63a4ff',
          main: '#1976d2',
          dark: '#004ba0',
          contrastText: '#ffffff',
        },
      },
  });

export const HeaderStyle = makeStyles(() =>
  createStyles({
    root: {
      marginBottom: "3%",
      flexGrow: 1,
    },
    title: {
      flexGrow: 1,
      textAlign: "center", 
    }, 
    link: {
      color: "#000", 
      textDecoration: "none", 
    }
  })
);

export const HomeStyle = makeStyles(() => 
    createStyles({
        root: {
            marginRight: 'auto', 
            marginLeft: 'auto', 
            width: '50%', 
            textAlign: 'center', 
        },
        topPage: {
            paddingTop: '3%', 
            paddingBottom: '10%'
        }, 
        inputText: {
            width: '80%', 

        }, 
        button: {
            marginLeft: '2%', 
            width: '18%', 
        }
    })
)

export const RoomStyle = makeStyles(() =>
    createStyles({
        root: {
            display: 'block', 
            width: '820px', 
            marginRight: 'auto', 
            marginLeft: 'auto'
        }, 
        room: {
            display: 'flex'
        }, 
        camera: {
            margin: '10px'
        }, 
        inputID: {
            marginTop: '5%', 
            marginRight: 'auto', 
            marginLeft: 'auto', 
            width: '80%'
        },
    })
)